Compare commits

...

8 Commits

45 changed files with 3867 additions and 100 deletions

View File

@ -66,11 +66,32 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific), ("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific),
("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable), ("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable),
("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates), ("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates),
("Character discovery rejects sentence-start words", CharacterDiscoveryRejectsSentenceStartWords),
("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext), ("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext),
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics), ("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics),
("Illustration evidence trace does not recursively store previous evidence", IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence) ("Illustration evidence trace does not recursively store previous evidence", IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence),
("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions),
("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation),
("StoryPosition derives canonical scene context identity", StoryPositionDerivesCanonicalSceneContextIdentity),
("Writing Context route uses canonical story context service", WritingContextRouteUsesCanonicalStoryContextService),
("Writing Context client follows canonical story position events", WritingContextClientFollowsCanonicalStoryPositionEvents),
("Writing Context preserves selected tab and avoids event loops", WritingContextPreservesSelectedTabAndAvoidsEventLoops),
("Writing Intent route uses canonical story context and planning data", WritingIntentRouteUsesCanonicalStoryContextAndPlanningData),
("Writing Intent client uses shared writing follow helper", WritingIntentClientUsesSharedWritingFollowHelper),
("Writing mode pages are isolated from global Word navigation", WritingModePagesAreIsolatedFromGlobalWordNavigation),
("Writing pair shares canonical follow infrastructure", WritingPairSharesCanonicalFollowInfrastructure),
("Writing workspace presence scopes browser windows", WritingWorkspacePresenceScopesBrowserWindows),
("Writing workspace client uses session window identity and stable names", WritingWorkspaceClientUsesSessionIdentityAndStableNames),
("Writing workspace launcher uses progressive screen placement", WritingWorkspaceLauncherUsesProgressiveScreenPlacement),
("Writing workspace pages register roles without changing follow behavior", WritingWorkspacePagesRegisterRolesWithoutChangingFollowBehavior),
("Word Companion exposes writing workspace launch actions", WordCompanionExposesWritingWorkspaceLaunchActions),
("Word manuscript cleanup classifies PlotDirector markers by tag only", WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly),
("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent),
("Word manuscript cleanup UI separates unlink from clean unlink", WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink),
("Word manuscript unlink keeps Story Intelligence data untouched", WordManuscriptUnlinkKeepsStoryIntelligenceDataUntouched),
("Word manuscript cleanup remains relinkable and idempotent", WordManuscriptCleanupRemainsRelinkableAndIdempotent)
}; };
foreach (var test in tests) foreach (var test in tests)
@ -86,6 +107,390 @@ static void RepairsTrailingDecimalConfidence()
Assert(result.RepairedJson.Contains("\"confidence\":0.0", StringComparison.Ordinal), result.RepairedJson); Assert(result.RepairedJson.Contains("\"confidence\":0.0", StringComparison.Ordinal), result.RepairedJson);
} }
static void StoryPositionRejectsInvalidRuntimeScenePositions()
{
Assert(StoryPosition.FromRuntimeCurrentScene(new WordCompanionRuntimeCurrentSceneRequest
{
DocumentGuid = Guid.NewGuid(),
ProjectId = 1,
BookId = 2,
ChapterId = 3,
SceneId = 0
}) is null, "Runtime scene positions require a valid SceneId.");
Assert(StoryPosition.FromTimelineSelection(0, 2, 3, 4) is null, "Story positions require a valid ProjectId.");
}
static void StoryPositionBuildsCanonicalRuntimeSceneNavigation()
{
var response = new WordCompanionRuntimeCurrentSceneResponse
{
ProjectId = 10,
BookId = 20,
ChapterId = 30,
SceneId = 40,
SceneUrl = "/Scenes/Edit/40"
};
var position = response.Position;
Assert(position is not null, "Runtime response should expose a canonical StoryPosition.");
Assert(position!.ProjectId == 10 && position.BookId == 20 && position.ChapterId == 30 && position.SceneId == 40, "Runtime response position IDs were not preserved.");
Assert(position.SceneEditUrl == "/Scenes/Edit/40", "Scene positions must use the canonical Scene Edit route.");
}
static void StoryPositionDerivesCanonicalSceneContextIdentity()
{
var model = new SceneEditViewModel
{
SceneID = 7,
ChapterID = 6,
Project = new Project { ProjectID = 4 },
Book = new Book { BookID = 5 },
Chapter = new Chapter { ChapterID = 6 }
};
var position = StoryPosition.FromScene(model);
Assert(position is not null, "Scene workspace should expose a canonical StoryPosition.");
Assert(position!.ProjectId == 4 && position.BookId == 5 && position.ChapterId == 6 && position.SceneId == 7, "Scene workspace position IDs were not preserved.");
}
static void WritingContextRouteUsesCanonicalStoryContextService()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs"));
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs"));
Assert(controller.Contains("[Route(\"Writing\")", StringComparison.Ordinal), "Writing Context controller should expose the /Writing route prefix.");
Assert(controller.Contains("[HttpGet(\"Context\")", StringComparison.Ordinal), "Writing Context shell route is missing.");
Assert(controller.Contains("[HttpGet(\"Context/Scene\")", StringComparison.Ordinal), "Writing Context partial route is missing.");
Assert(controller.Contains("IStoryContextService storyContext", StringComparison.Ordinal), "Writing Context must consume IStoryContextService.");
Assert(controller.Contains("storyContext.GetForSceneAsync", StringComparison.Ordinal), "Writing Context scene requests must load canonical story context.");
Assert(!controller.Contains("ISceneService", StringComparison.Ordinal), "Writing Context should not bypass canonical story context with ISceneService.");
Assert(viewModel.Contains("StoryContextViewModel? Context", StringComparison.Ordinal), "Writing Context display model should carry canonical story context.");
}
static void WritingContextClientFollowsCanonicalStoryPositionEvents()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
var view = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml"));
Assert(view.Contains("data-writing-context", StringComparison.Ordinal), "Writing Context shell should expose a stable script root.");
Assert(view.Contains("data-writing-mode=\"context\"", StringComparison.Ordinal), "Writing Context should identify itself as a writing-mode page.");
Assert(view.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Writing Context should load the shared writing follow helper.");
Assert(initializer.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Context should initialize the shared writing follow helper.");
Assert(script.Contains("window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Writing Context must subscribe to canonical browser story-position events.");
Assert(script.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "Writing Context must bridge SignalR story-position events.");
Assert(script.Contains("fetch(url", StringComparison.Ordinal), "Writing Context should refresh context content without full page navigation.");
Assert(initializer.Contains("[data-writing-context-scene-select]", StringComparison.Ordinal), "Writing Context manual fallback selector should be wired.");
Assert(script.Contains("publishChanged", StringComparison.Ordinal), "Manual scene selection should publish canonical story-position changes.");
}
static void WritingContextPreservesSelectedTabAndAvoidsEventLoops()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
Assert(initializer.Contains("plotdirector.writingContext.activeTab", StringComparison.Ordinal), "Writing Context should remember the selected tab.");
Assert(script.Contains("activateTab(tab)", StringComparison.Ordinal), "Writing Context should restore the selected tab after content refresh.");
Assert(script.Contains("position.sceneId === currentSceneId", StringComparison.Ordinal), "Writing Context should ignore duplicate current-scene events.");
Assert(script.Contains("position.sceneId === loadingSceneId", StringComparison.Ordinal), "Writing Context should ignore duplicate in-flight scene events.");
Assert(!script.Contains("window.location.assign", StringComparison.Ordinal) && !script.Contains("window.location.href", StringComparison.Ordinal), "Writing Context must not navigate away when Word changes scene.");
}
static void WritingIntentRouteUsesCanonicalStoryContextAndPlanningData()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs"));
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs"));
var contentView = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml"));
Assert(controller.Contains("[HttpGet(\"Intent\")", StringComparison.Ordinal), "Writing Intent shell route is missing.");
Assert(controller.Contains("[HttpGet(\"Intent/Scene\")", StringComparison.Ordinal), "Writing Intent partial route is missing.");
Assert(controller.Contains("IStoryContextService storyContext", StringComparison.Ordinal), "Writing Intent must share canonical story context service injection.");
Assert(controller.Contains("storyContext.GetForSceneAsync", StringComparison.Ordinal), "Writing Intent scene requests must use canonical story context.");
Assert(controller.Contains("IWriterWorkspaceService writerWorkspace", StringComparison.Ordinal), "Writing Intent should reuse existing writer planning data.");
Assert(controller.Contains("writerWorkspace.GetChapterWorkflowAsync", StringComparison.Ordinal), "Writing Intent should load chapter workflow planning data.");
Assert(controller.Contains("projectId.HasValue && chapterWorkflow.Project.ProjectID != projectId.Value", StringComparison.Ordinal), "Writing Intent should reject mismatched project scope.");
Assert(controller.Contains("bookId.HasValue && chapterWorkflow.Book.BookID != bookId.Value", StringComparison.Ordinal), "Writing Intent should reject mismatched book scope.");
Assert(viewModel.Contains("public sealed class WritingIntentDisplayViewModel", StringComparison.Ordinal), "Writing Intent display model is missing.");
Assert(viewModel.Contains("StoryContextViewModel? Context", StringComparison.Ordinal), "Writing Intent display should carry canonical story context.");
Assert(viewModel.Contains("ChapterWorkflowViewModel? ChapterWorkflow", StringComparison.Ordinal), "Writing Intent display should carry chapter planning workflow.");
Assert(contentView.Contains("No current scene. Showing chapter intent.", StringComparison.Ordinal), "Writing Intent should support chapter-only fallback.");
Assert(contentView.Contains("No scene-specific plan. Showing chapter intent.", StringComparison.Ordinal), "Writing Intent should prefer chapter intent when scene planning is sparse.");
}
static void WritingIntentClientUsesSharedWritingFollowHelper()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var helper = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-intent.js"));
var view = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml"));
var contentView = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml"));
Assert(view.Contains("data-writing-intent", StringComparison.Ordinal), "Writing Intent shell should expose a stable script root.");
Assert(view.Contains("data-writing-mode=\"intent\"", StringComparison.Ordinal), "Writing Intent should identify itself as a writing-mode page.");
Assert(view.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Writing Intent should load the shared writing follow helper.");
Assert(initializer.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Intent should initialize the shared helper.");
Assert(initializer.Contains("plotdirector.writingIntent.activeTab", StringComparison.Ordinal), "Writing Intent should remember the selected tab.");
Assert(initializer.Contains("defaultEndpoint: \"/Writing/Intent/Scene\"", StringComparison.Ordinal), "Writing Intent should refresh from its partial route.");
Assert(initializer.Contains("allowChapterOnly: true", StringComparison.Ordinal), "Writing Intent should accept canonical chapter-only positions.");
Assert(helper.Contains("window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Writing Intent should receive canonical story-position events through the shared helper.");
Assert(helper.Contains("fetch(url", StringComparison.Ordinal), "Writing Intent should update in place.");
Assert(helper.Contains("position.sceneId === currentSceneId", StringComparison.Ordinal), "Writing Intent should ignore duplicate current-scene events.");
Assert(helper.Contains("position.sceneId === loadingSceneId", StringComparison.Ordinal), "Writing Intent should ignore duplicate in-flight events.");
Assert(helper.Contains("position.bookId && position.bookId !== scopedBookId", StringComparison.Ordinal), "Writing Intent should ignore unrelated scoped book events.");
Assert(helper.Contains("publishChanged", StringComparison.Ordinal), "Writing Intent manual fallback should publish canonical story-position changes.");
Assert(contentView.Contains("data-writing-intent-scene-select", StringComparison.Ordinal), "Writing Intent manual scene selector should be wired.");
Assert(contentView.Contains("data-writing-intent-chapter-select", StringComparison.Ordinal), "Writing Intent manual chapter selector should be wired.");
Assert(!helper.Contains("window.location.assign", StringComparison.Ordinal) && !helper.Contains("window.location.href", StringComparison.Ordinal), "Writing Intent must not navigate away when Word changes scene.");
}
static void WritingModePagesAreIsolatedFromGlobalWordNavigation()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var site = File.ReadAllText(Path.Combine(root, "wwwroot/js/site.js"));
Assert(site.Contains("document.querySelector(\"[data-writing-mode]\")", StringComparison.Ordinal), "Global Word follow should recognize writing-mode markers.");
Assert(site.Contains("path === \"/writing/context\"", StringComparison.Ordinal), "Writing Context route should be exempt from global scene navigation.");
Assert(site.Contains("path === \"/writing/intent\"", StringComparison.Ordinal), "Writing Intent route should be exempt from global scene navigation.");
Assert(site.Contains("window.location.assign(url)", StringComparison.Ordinal), "Normal pages should retain existing Word follow navigation.");
Assert(site.Contains("safeRelativeUrl(payload?.sceneUrl || payload?.SceneUrl || \"\", \"/Scenes/Edit/\")", StringComparison.Ordinal), "Normal scene follow should still target canonical Scene Edit URLs.");
}
static void WritingPairSharesCanonicalFollowInfrastructure()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var context = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
var intent = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-intent.js"));
var helper = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
Assert(context.Contains("source: \"WritingContext\"", StringComparison.Ordinal), "Writing Context should identify its canonical event source.");
Assert(intent.Contains("source: \"WritingIntent\"", StringComparison.Ordinal), "Writing Intent should identify its canonical event source.");
Assert(context.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Context should use shared follow initialization.");
Assert(intent.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Intent should use shared follow initialization.");
Assert(helper.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "The shared helper should bridge Word story-position events for both pages.");
Assert(helper.Contains("document.addEventListener(window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Both pages should react to the same canonical browser event.");
}
static void WritingWorkspacePresenceScopesBrowserWindows()
{
var service = new WritingWorkspacePresenceService();
var word = new WordCompanionPresenceStatus
{
UserID = 9,
IsConnected = true,
DocumentOpen = true,
LinkedProjectID = 10,
LinkedBookID = 20
};
service.RegisterAsync(9, "launcher-1", new WritingWindowRegistration
{
BrowserWindowID = "launcher",
ProjectID = 10,
BookID = 20,
WritingRole = WritingWorkspaceRoles.Launcher,
CurrentRoute = "/Writer"
}, word).GetAwaiter().GetResult();
service.RegisterAsync(9, "context-1", new WritingWindowRegistration
{
BrowserWindowID = "context",
ProjectID = 10,
BookID = 20,
WritingRole = WritingWorkspaceRoles.Context,
CurrentRoute = "/Writing/Context"
}, word).GetAwaiter().GetResult();
service.RegisterAsync(9, "intent-1", new WritingWindowRegistration
{
BrowserWindowID = "intent",
ProjectID = 10,
BookID = 20,
WritingRole = WritingWorkspaceRoles.Intent,
CurrentRoute = "/Writing/Intent"
}, word).GetAwaiter().GetResult();
service.RegisterAsync(9, "other-context", new WritingWindowRegistration
{
BrowserWindowID = "other-context",
ProjectID = 10,
BookID = 21,
WritingRole = WritingWorkspaceRoles.Context,
CurrentRoute = "/Writing/Context"
}, word).GetAwaiter().GetResult();
var status = service.GetStatusAsync(9, new WritingWorkspaceScope { ProjectID = 10, BookID = 20 }, word).GetAwaiter().GetResult();
Assert(status.WordCompanionConnected && status.WordDocumentOpen, "Writing workspace should include Word Companion presence.");
Assert(status.ContextOpen && status.IntentOpen, "Matching Context and Intent windows should be open.");
Assert(status.Context?.BrowserWindowID == "context", "Workspace status should choose the matching book's Context window.");
Assert(status.Windows.All(window => window.BookID == 20), "Workspace status should not include another book's windows.");
service.DisconnectAsync("context-1", word).GetAwaiter().GetResult();
status = service.GetStatusAsync(9, new WritingWorkspaceScope { ProjectID = 10, BookID = 20 }, word).GetAwaiter().GetResult();
Assert(!status.ContextOpen && status.IntentOpen, "Disconnected Context should be removed without affecting Intent.");
}
static void WritingWorkspaceClientUsesSessionIdentityAndStableNames()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace.js"));
var service = File.ReadAllText(Path.Combine(root, "Services/WritingWorkspacePresenceService.cs"));
Assert(script.Contains("sessionStorage.getItem(storageKeys.windowId)", StringComparison.Ordinal), "Browser window IDs must come from sessionStorage.");
Assert(script.Contains("sessionStorage.setItem(storageKeys.windowId", StringComparison.Ordinal), "Browser window IDs must be stored per tab/window.");
Assert(!script.Contains("localStorage.getItem(storageKeys.windowId)", StringComparison.Ordinal), "Browser window IDs must not be shared through localStorage.");
Assert(script.Contains("PlotDirector${role}_${suffix}", StringComparison.Ordinal), "Context and Intent windows should use stable named targets.");
Assert(script.Contains("Book_${bookId}", StringComparison.Ordinal), "Stable writing window names should be book scoped.");
Assert(script.Contains("windowRefs = new Map()", StringComparison.Ordinal), "Launcher-created window references should be retained for focus.");
Assert(script.Contains("RequestWritingWindowFocus", StringComparison.Ordinal), "Launcher should fall back to SignalR focus requests.");
Assert(script.Contains("connection.onreconnected", StringComparison.Ordinal), "Writing workspace windows should re-register after SignalR reconnect.");
Assert(service.Contains("ActiveTimeout = TimeSpan.FromSeconds(45)", StringComparison.Ordinal) && service.Contains("MarkStaleOfflineAsync", StringComparison.Ordinal), "Writing workspace presence should expire stale windows.");
}
static void WritingWorkspaceLauncherUsesProgressiveScreenPlacement()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace.js"));
var launcher = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace-launcher.js"));
var view = File.ReadAllText(Path.Combine(root, "Views/Writer/Index.cshtml"));
Assert(script.Contains("getScreenDetails", StringComparison.Ordinal), "Workspace launcher should use the Window Management API when available.");
Assert(script.Contains("window-management", StringComparison.Ordinal), "Workspace launcher should check/request window-management permission.");
Assert(script.Contains("sort((a, b) => a.left - b.left", StringComparison.Ordinal), "Screen topology should be derived from physical coordinates.");
Assert(script.Contains("mode: \"three-screen\"", StringComparison.Ordinal), "Three-screen layout decision is missing.");
Assert(script.Contains("contextDisplay: preferences.contextDisplay === \"right\" ? ordered[ordered.length - 1] : ordered[0]", StringComparison.Ordinal), "Context should default to the left display.");
Assert(script.Contains("intentDisplay: preferences.intentDisplay === \"left\" ? ordered[0] : ordered[ordered.length - 1]", StringComparison.Ordinal), "Intent should default to the right display.");
Assert(script.Contains("mode: \"two-screen\"", StringComparison.Ordinal), "Two-screen fallback is missing.");
Assert(script.Contains("mode: \"single-screen\"", StringComparison.Ordinal), "One-screen fallback is missing.");
Assert(script.Contains("localStorage.setItem(storageKeys.preferences", StringComparison.Ordinal), "Remembered layout preferences are missing.");
Assert(script.Contains("window.open(url, name, popupFeatures(display))", StringComparison.Ordinal), "Writing windows should be opened from explicit launcher clicks with placement features.");
Assert(launcher.Contains("requestScreens: true", StringComparison.Ordinal), "Screen permission should be requested from the Start action.");
Assert(view.Contains("data-start-writing-workspace", StringComparison.Ordinal), "Writer dashboard launcher start button is missing.");
Assert(view.Contains("data-focus-writing-context", StringComparison.Ordinal), "Focus Context action is missing.");
Assert(view.Contains("data-focus-writing-intent", StringComparison.Ordinal), "Focus Intent action is missing.");
}
static void WritingWorkspacePagesRegisterRolesWithoutChangingFollowBehavior()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var contextView = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml"));
var intentView = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml"));
var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
Assert(contextView.Contains("writingRole: window.PlotDirectorWritingWorkspace.roles.context", StringComparison.Ordinal), "Writing Context should register its workspace role.");
Assert(intentView.Contains("writingRole: window.PlotDirectorWritingWorkspace.roles.intent", StringComparison.Ordinal), "Writing Intent should register its workspace role.");
Assert(contextView.Contains("data-writing-mode=\"context\"", StringComparison.Ordinal), "Writing Context should remain a writing-mode page.");
Assert(intentView.Contains("data-writing-mode=\"intent\"", StringComparison.Ordinal), "Writing Intent should remain a writing-mode page.");
Assert(follow.Contains("registerWorkspaceRole", StringComparison.Ordinal), "Writing follow should refresh presence scope after Word updates.");
Assert(follow.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "Existing canonical Word follow should remain.");
Assert(!follow.Contains("window.location.assign", StringComparison.Ordinal) && !follow.Contains("window.location.href", StringComparison.Ordinal), "Writing pages must still update in place without navigation.");
}
static void WordCompanionExposesWritingWorkspaceLaunchActions()
{
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-open-writing-workspace", StringComparison.Ordinal), "Word Companion should expose Open Workspace.");
Assert(view.Contains("data-open-writing-context", StringComparison.Ordinal), "Word Companion should expose Open Context.");
Assert(view.Contains("data-open-writing-intent", StringComparison.Ordinal), "Word Companion should expose Open Intent.");
Assert(script.Contains("writingWindowName", StringComparison.Ordinal), "Word Companion should use stable writing window names.");
Assert(script.Contains("PlotDirector${role}_${suffix}", StringComparison.Ordinal), "Word Companion writing window names should be scoped.");
Assert(script.Contains("openWritingWorkspaceButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Workspace button should be wired.");
Assert(script.Contains("openWritingContextButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Context button should be wired.");
Assert(script.Contains("openWritingIntentButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Intent button should be wired.");
}
static void WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
var classifierStart = script.IndexOf("const classifyPlotDirectorContentControl", StringComparison.Ordinal);
var classifierEnd = script.IndexOf("window.PlotDirectorWordMarkers", classifierStart, StringComparison.Ordinal);
Assert(classifierStart >= 0 && classifierEnd > classifierStart, "Word host should expose a stable PlotDirector marker classifier.");
var classifier = script[classifierStart..classifierEnd];
Assert(classifier.Contains("const tag = String(control?.tag || \"\").trim().toUpperCase()", StringComparison.Ordinal), "Marker cleanup should classify content controls from the tag field.");
Assert(script.Contains("chapter: \"PD-CHAPTER-\"", StringComparison.Ordinal), "Chapter marker prefix is missing.");
Assert(script.Contains("scene: \"PD-SCENE-\"", StringComparison.Ordinal), "Scene marker prefix is missing.");
Assert(classifier.Contains("tag.startsWith(plotDirectorMarkerPrefixes.chapter)", StringComparison.Ordinal), "Classifier should use the chapter tag prefix.");
Assert(classifier.Contains("tag.startsWith(plotDirectorMarkerPrefixes.scene)", StringComparison.Ordinal), "Classifier should use the scene tag prefix.");
Assert(!classifier.Contains("title", StringComparison.OrdinalIgnoreCase), "Marker cleanup must not delete arbitrary controls by display title.");
}
static void WordManuscriptCleanupPreservesContentControlContent()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
Assert(script.Contains("const removePlotDirectorContentControlsPreservingContent", StringComparison.Ordinal), "Clean unlink should have a dedicated marker removal path.");
Assert(script.Contains("context.document.contentControls", StringComparison.Ordinal), "Clean unlink should inspect document content controls.");
Assert(script.Contains("control.delete(true);", StringComparison.Ordinal), "PlotDirector marker cleanup must call Word.ContentControl.delete(true) to preserve visible content.");
Assert(!script.Contains("control.delete(false)", StringComparison.Ordinal), "PlotDirector marker cleanup must not delete marker content.");
Assert(!script.Contains("control.delete();", StringComparison.Ordinal), "PlotDirector marker cleanup must pass keepContent explicitly.");
Assert(script.Contains("await inspectPlotDirectorMarkers();", StringComparison.Ordinal), "Clean unlink should verify marker counts after removal.");
Assert(script.Contains("latestMarkerState.totalCount > 0", StringComparison.Ordinal), "Clean unlink should stop when PlotDirector markers remain.");
Assert(script.Contains("cleanup verification failed", StringComparison.Ordinal), "Clean unlink should fail closed when marker verification cannot complete.");
}
static void WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink()
{
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-marker-binding-status", StringComparison.Ordinal), "Maintenance UI should report document binding status.");
Assert(view.Contains("data-marker-chapter-count", StringComparison.Ordinal), "Maintenance UI should report chapter marker counts.");
Assert(view.Contains("data-marker-scene-count", StringComparison.Ordinal), "Maintenance UI should report scene marker counts.");
Assert(view.Contains("Unlink from PlotDirector", StringComparison.Ordinal), "Existing unlink behavior should remain visible as a separate action.");
Assert(view.Contains("Hidden PlotDirector anchors will be left in place", StringComparison.Ordinal), "Existing unlink dialog should state markers are retained.");
Assert(view.Contains("Clean and unlink document", StringComparison.Ordinal), "Full cleanup should be a separate explicit action.");
Assert(view.Contains("data-clean-unlink-word-document", StringComparison.Ordinal), "Clean unlink button is missing.");
Assert(view.Contains("data-clean-unlink-word-document-dialog", StringComparison.Ordinal), "Clean unlink confirmation dialog is missing.");
Assert(view.Contains("Visible manuscript text, headings, formatting, comments, and tracked changes should remain", StringComparison.Ordinal), "Clean unlink dialog should describe content preservation.");
Assert(script.Contains("requestUnlinkWordDocumentConfirmation", StringComparison.Ordinal), "Existing unlink confirmation path should remain.");
Assert(script.Contains("requestCleanUnlinkWordDocumentConfirmation", StringComparison.Ordinal), "Clean unlink should use its own confirmation path.");
}
static void WordManuscriptUnlinkKeepsStoryIntelligenceDataUntouched()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var unlinkSql = File.ReadAllText(Path.Combine(root, "Sql/108_Phase16Q_ManuscriptUnlink.sql"));
var sceneResultSql = File.ReadAllText(Path.Combine(root, "Sql/115_Phase20I_StoryIntelligenceResultPersistence.sql"));
var importCommitSql = File.ReadAllText(Path.Combine(root, "Sql/121_Phase20V_StoryIntelligenceImportCommit.sql"));
Assert(unlinkSql.Contains("CREATE OR ALTER PROCEDURE dbo.ManuscriptDocument_UnlinkBook", StringComparison.Ordinal), "Server unlink procedure is missing.");
Assert(unlinkSql.Contains("IsLinkedToManuscript = 0", StringComparison.Ordinal), "Server unlink should clear manuscript link flags.");
Assert(unlinkSql.Contains("ManuscriptDocumentID = NULL", StringComparison.Ordinal), "Server unlink should clear manuscript document references.");
Assert(unlinkSql.Contains("IsActive = 0", StringComparison.Ordinal), "Server unlink should deactivate only the manuscript document binding row.");
Assert(!unlinkSql.Contains("StoryIntelligence", StringComparison.Ordinal), "Server unlink must not modify Story Intelligence tables.");
Assert(!unlinkSql.Contains("DELETE FROM dbo.Chapters", StringComparison.OrdinalIgnoreCase), "Server unlink must not delete chapters.");
Assert(!unlinkSql.Contains("DELETE FROM dbo.Scenes", StringComparison.OrdinalIgnoreCase), "Server unlink must not delete scenes.");
Assert(sceneResultSql.Contains("StartParagraph", StringComparison.Ordinal) && sceneResultSql.Contains("EndParagraph", StringComparison.Ordinal), "Story Intelligence scene result positions should remain database-backed.");
Assert(importCommitSql.Contains("SourceStartParagraph", StringComparison.Ordinal) && importCommitSql.Contains("SourceEndParagraph", StringComparison.Ordinal), "Story Intelligence source positions should remain database-backed.");
}
static void WordManuscriptCleanupRemainsRelinkableAndIdempotent()
{
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"));
var saveSql = File.ReadAllText(Path.Combine(root, "Sql/094_Phase16B_ManuscriptBindingLinkedStructures.sql"));
var unlinkSql = File.ReadAllText(Path.Combine(root, "Sql/108_Phase16Q_ManuscriptUnlink.sql"));
Assert(script.Contains("const binding = currentDocumentBinding();", StringComparison.Ordinal), "Clean unlink should tolerate missing local binding metadata.");
Assert(script.Contains("if (binding?.documentGuid && Number.isInteger(binding.bookId) && binding.bookId > 0)", StringComparison.Ordinal), "Server unlink should be skipped safely when no binding exists.");
Assert(script.Contains("await removeDocumentBinding();", StringComparison.Ordinal), "Clean unlink should remove local binding settings after marker verification.");
Assert(service.Contains("documentGuid = Guid.NewGuid();", StringComparison.Ordinal), "Relinking should mint a new document GUID when needed.");
Assert(service.Contains("manuscriptDocuments.SaveAsync(request.BookId, documentGuid, RequireUserId())", StringComparison.Ordinal), "Relinking should still flow through the manuscript document repository.");
Assert(repository.Contains("\"dbo.ManuscriptDocument_Save\"", StringComparison.Ordinal), "Relinking should still use the manuscript document save procedure.");
Assert(saveSql.Contains("ManuscriptDocument_Save", StringComparison.Ordinal) && saveSql.Contains("IsActive", StringComparison.Ordinal), "Server save procedure should support a new active document binding.");
Assert(unlinkSql.Contains("@DocumentGuid IS NULL", StringComparison.Ordinal) && unlinkSql.Contains("00000000-0000-0000-0000-000000000000", StringComparison.Ordinal), "Server unlink should tolerate repeated or metadata-free unlink requests.");
}
static void StripsMarkdownFence() static void StripsMarkdownFence()
{ {
var result = StoryIntelligenceJsonRepair.Repair(""" var result = StoryIntelligenceJsonRepair.Repair("""
@ -872,7 +1277,7 @@ static void CharacterDiscoveryDoesNotTruncateScanReviewCandidates()
var builder = new StringBuilder(); var builder = new StringBuilder();
for (var index = 0; index < 60; index++) for (var index = 0; index < 60; index++)
{ {
var name = $"Person{(char)('a' + index / 26)}{(char)('a' + index % 26)}"; var name = $"Mr Person{(char)('a' + index / 26)}{(char)('a' + index % 26)}";
builder.Append(name).Append(" said hello. ").Append(name).Append(" smiled. "); builder.Append(name).Append(" said hello. ").Append(name).Append(" smiled. ");
} }
@ -880,6 +1285,25 @@ static void CharacterDiscoveryDoesNotTruncateScanReviewCandidates()
Assert(candidates.Count >= 60, $"Character discovery should not truncate the scan review to 50 candidates; got {candidates.Count}."); Assert(candidates.Count >= 60, $"Character discovery should not truncate the scan review to 50 candidates; got {candidates.Count}.");
} }
static void CharacterDiscoveryRejectsSentenceStartWords()
{
var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "Character discovery method could not be found.");
var text = """
Silence filled the room. Silence made everything worse.
Morning came too quickly. Morning dragged at the windows.
Eyes looked toward the door. Eyes followed the sound.
Colin said nothing. Debbie smiled at Colin. Mr Wilkinson opened the file.
""";
var candidates = (IReadOnlyList<WordCompanionCharacterCandidateDto>)method!.Invoke(null, [text, false])!;
Assert(candidates.Any(candidate => candidate.Text == "Colin"), "Real common character names should still be detected.");
Assert(candidates.Any(candidate => candidate.Text == "Debbie"), "Real common character names should still be detected.");
Assert(candidates.Any(candidate => candidate.Text == "Mr Wilkinson"), "Titled character names should still be detected.");
Assert(!candidates.Any(candidate => candidate.Text is "Silence" or "Morning" or "Eyes"), "Repeated sentence-start words must not become character candidates.");
}
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel() static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
{ {
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml")); var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using PlotLine.Models;
using PlotLine.Services; using PlotLine.Services;
namespace PlotLine.Controllers; namespace PlotLine.Controllers;
@ -9,7 +10,10 @@ public sealed class RelationshipMapController(IRelationshipMapService relationsh
{ {
public async Task<IActionResult> Index(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork = false) public async Task<IActionResult> Index(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork = false)
{ {
var model = await relationshipMap.GetAsync(projectId, bookId, chapterId, sceneId, focusCharacterId, showFullNetwork); var position = StoryPosition.FromTimelineSelection(projectId, bookId, chapterId, sceneId);
var model = position is null
? null
: await relationshipMap.GetAsync(position, focusCharacterId, showFullNetwork);
return model is null ? NotFound() : View(model); return model is null ? NotFound() : View(model);
} }
} }

View File

@ -136,7 +136,19 @@ public sealed class WordCompanionController(
response.BookId, response.BookId,
response.ChapterId, response.ChapterId,
response.SceneId, response.SceneId,
response.SceneUrl response.SceneUrl,
response.Position
});
await BroadcastToCurrentUserAsync("storyPositionChanged", new
{
Source = "WordCompanion",
response.ProjectId,
response.BookId,
response.ChapterId,
response.SceneId,
CanonicalUrl = response.SceneUrl,
response.Position
}); });
return Ok(response); return Ok(response);

View File

@ -0,0 +1,191 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
[Authorize]
[Route("Writing")]
public sealed class WritingController(
IStoryContextService storyContext,
IRelationshipMapService relationshipMap,
IFloorPlanService floorPlans,
IWriterWorkspaceService writerWorkspace) : Controller
{
[HttpGet("Context")]
public async Task<IActionResult> Context(int? projectId, int? bookId, int? chapterId, int? sceneId)
{
var model = new WritingContextPageViewModel
{
ProjectID = projectId,
BookID = bookId,
ChapterID = chapterId,
SceneID = sceneId,
Display = sceneId.HasValue
? await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual context")
: new WritingContextDisplayViewModel { FollowStatus = "Connected, waiting for scene" }
};
return View(model);
}
[HttpGet("Intent")]
public async Task<IActionResult> Intent(int? projectId, int? bookId, int? chapterId, int? sceneId)
{
var model = new WritingIntentPageViewModel
{
ProjectID = projectId,
BookID = bookId,
ChapterID = chapterId,
SceneID = sceneId,
Display = sceneId.HasValue || chapterId.HasValue
? await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual intent")
: new WritingIntentDisplayViewModel { FollowStatus = "Connected, waiting for scene" }
};
return View(model);
}
[HttpGet("Context/Scene")]
public async Task<IActionResult> ContextScene(int? projectId, int? bookId, int? chapterId, int? sceneId)
{
if (!sceneId.HasValue)
{
return PartialView("~/Views/Writing/_WritingContextContent.cshtml", new WritingContextDisplayViewModel
{
FollowStatus = "Connected, waiting for scene"
});
}
var model = await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word");
return model?.HasScene == true
? PartialView("~/Views/Writing/_WritingContextContent.cshtml", model)
: NotFound();
}
[HttpGet("Intent/Scene")]
public async Task<IActionResult> IntentScene(int? projectId, int? bookId, int? chapterId, int? sceneId)
{
if (!sceneId.HasValue && !chapterId.HasValue)
{
return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", new WritingIntentDisplayViewModel
{
FollowStatus = "Connected, waiting for scene"
});
}
var model = await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word");
return model is not null && (model.HasScene || model.HasChapter)
? PartialView("~/Views/Writing/_WritingIntentContent.cshtml", model)
: NotFound();
}
private async Task<WritingContextDisplayViewModel?> BuildDisplayAsync(int? projectId, int? bookId, int? chapterId, int? sceneId, string followStatus)
{
if (!sceneId.HasValue)
{
return new WritingContextDisplayViewModel { FollowStatus = followStatus };
}
var context = await storyContext.GetForSceneAsync(sceneId.Value, projectId, bookId);
if (context?.Scene is null)
{
return null;
}
var position = context.Position;
var relationships = await relationshipMap.GetAsync(position, null, true);
var floorPlan = context.Scene.FloorPlanID.HasValue
? await floorPlans.GetEditorAsync(context.Scene.FloorPlanID.Value)
: null;
return new WritingContextDisplayViewModel
{
Context = context,
RelationshipMap = relationships,
FloorPlan = floorPlan,
FollowStatus = followStatus,
SceneOptions = BuildSceneOptions(context.Scene)
};
}
private async Task<WritingIntentDisplayViewModel?> BuildIntentDisplayAsync(int? projectId, int? bookId, int? chapterId, int? sceneId, string followStatus)
{
StoryContextViewModel? context = null;
if (sceneId.HasValue)
{
context = await storyContext.GetForSceneAsync(sceneId.Value, projectId, bookId);
if (context?.Scene is null)
{
return null;
}
}
var resolvedChapterId = context?.Position.ChapterId ?? chapterId;
var chapterWorkflow = resolvedChapterId.HasValue
? await writerWorkspace.GetChapterWorkflowAsync(resolvedChapterId.Value)
: null;
if (chapterWorkflow is not null)
{
if (projectId.HasValue && chapterWorkflow.Project.ProjectID != projectId.Value)
{
return null;
}
if (bookId.HasValue && chapterWorkflow.Book.BookID != bookId.Value)
{
return null;
}
}
if (context is null && chapterWorkflow is null)
{
return null;
}
return new WritingIntentDisplayViewModel
{
Context = context,
ChapterWorkflow = chapterWorkflow,
FollowStatus = followStatus,
SceneOptions = context?.Scene is not null
? BuildSceneOptions(context.Scene)
: BuildSceneOptions(chapterWorkflow)
};
}
private static IReadOnlyList<SelectListItem> BuildSceneOptions(SceneEditViewModel scene)
{
var options = scene.SceneOptions
.Select(x => new SelectListItem(x.Text, x.Value, x.Value == scene.SceneID.ToString()))
.ToList();
if (!options.Any(x => x.Value == scene.SceneID.ToString()))
{
options.Insert(0, new SelectListItem(
$"{scene.Book?.BookDisplayTitle ?? "Book"} / Ch {scene.Chapter?.ChapterNumber:g} / Scene {scene.SceneNumber:g}: {scene.SceneTitle}",
scene.SceneID.ToString(),
true));
}
return options
.OrderByDescending(x => x.Selected)
.ThenBy(x => x.Text)
.ToList();
}
private static IReadOnlyList<SelectListItem> BuildSceneOptions(ChapterWorkflowViewModel? chapterWorkflow)
{
return chapterWorkflow?.Scenes
.Select(x => new SelectListItem(
$"Scene {x.SceneNumber:g}: {x.SceneTitle}",
x.SceneID.ToString()))
.OrderBy(x => x.Text)
.ToList()
?? [];
}
}

View File

@ -10,6 +10,7 @@ namespace PlotLine.Hubs;
[Authorize] [Authorize]
public sealed class WordCompanionFollowHub( public sealed class WordCompanionFollowHub(
IWordCompanionPresenceService presence, IWordCompanionPresenceService presence,
IWritingWorkspacePresenceService writingPresence,
IOnboardingRepository onboarding, IOnboardingRepository onboarding,
IManuscriptScanPreviewStore scanStore, IManuscriptScanPreviewStore scanStore,
IOnboardingService onboardingService) : Hub IOnboardingService onboardingService) : Hub
@ -41,6 +42,7 @@ public sealed class WordCompanionFollowHub(
var status = await presence.RegisterAsync(userId, Context.ConnectionId, registration); var status = await presence.RegisterAsync(userId, Context.ConnectionId, registration);
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status); await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
await BroadcastWritingWorkspaceStatusAsync(userId, registration.LinkedProjectID, registration.LinkedBookID);
return status; return status;
} }
@ -49,9 +51,51 @@ public sealed class WordCompanionFollowHub(
var userId = RequireUserId(); var userId = RequireUserId();
var status = await presence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat); var status = await presence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat);
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status); await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
await BroadcastWritingWorkspaceStatusAsync(userId, heartbeat.LinkedProjectID, heartbeat.LinkedBookID);
return status; return status;
} }
public async Task<WritingWorkspaceStatus> WatchWritingWorkspace(WritingWorkspaceScope scope)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
return await WritingWorkspaceStatusAsync(userId, scope);
}
public async Task<WritingWorkspaceStatus> RegisterWritingWindow(WritingWindowRegistration registration)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
var status = await writingPresence.RegisterAsync(userId, Context.ConnectionId, registration, await presence.GetStatusAsync(userId));
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", status);
return status;
}
public async Task<WritingWorkspaceStatus> WritingWindowHeartbeat(WritingWindowHeartbeat heartbeat)
{
var userId = RequireUserId();
var status = await writingPresence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat, await presence.GetStatusAsync(userId));
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", status);
return status;
}
public async Task<WritingWorkspaceStatus> RequestWritingWindowFocus(WritingWorkspaceScope scope, string role)
{
var userId = RequireUserId();
var connectionId = await writingPresence.GetConnectionIdForRoleAsync(userId, scope, role);
if (!string.IsNullOrWhiteSpace(connectionId))
{
await Clients.Client(connectionId).SendAsync("WritingWorkspaceFocusRequested", new
{
WritingRole = WritingWorkspaceRoles.Normalise(role),
scope.ProjectID,
scope.BookID
});
}
return await WritingWorkspaceStatusAsync(userId, scope);
}
public async Task<ManuscriptScanState> StartOnboardingManuscriptScan() public async Task<ManuscriptScanState> StartOnboardingManuscriptScan()
{ {
var userId = RequireUserId(); var userId = RequireUserId();
@ -163,10 +207,13 @@ public sealed class WordCompanionFollowHub(
public override async Task OnDisconnectedAsync(Exception? exception) public override async Task OnDisconnectedAsync(Exception? exception)
{ {
int? disconnectedUserId = null;
var status = await presence.DisconnectAsync(Context.ConnectionId); var status = await presence.DisconnectAsync(Context.ConnectionId);
if (status is not null) if (status is not null)
{ {
disconnectedUserId = status.UserID;
await Clients.Group(PresenceGroup(status.UserID)).SendAsync("WordCompanionPresenceChanged", status); await Clients.Group(PresenceGroup(status.UserID)).SendAsync("WordCompanionPresenceChanged", status);
await BroadcastWritingWorkspaceStatusAsync(status.UserID, status.LinkedProjectID, status.LinkedBookID);
var failedScans = await scanStore.FailRunningForUserAsync( var failedScans = await scanStore.FailRunningForUserAsync(
status.UserID, status.UserID,
"The Word Companion disconnected before the scan finished. Reopen Word and try again."); "The Word Companion disconnected before the scan finished. Reopen Word and try again.");
@ -176,9 +223,30 @@ public sealed class WordCompanionFollowHub(
} }
} }
var writingStatus = await writingPresence.DisconnectAsync(
Context.ConnectionId,
disconnectedUserId.HasValue ? await presence.GetStatusAsync(disconnectedUserId.Value) : null);
if (writingStatus is not null)
{
await Clients.Group(PresenceGroup(writingStatus.UserID)).SendAsync("WritingWorkspacePresenceChanged", writingStatus);
}
await base.OnDisconnectedAsync(exception); await base.OnDisconnectedAsync(exception);
} }
private async Task<WritingWorkspaceStatus> WritingWorkspaceStatusAsync(int userId, WritingWorkspaceScope scope)
=> await writingPresence.GetStatusAsync(userId, scope, await presence.GetStatusAsync(userId));
private async Task BroadcastWritingWorkspaceStatusAsync(int userId, int? projectId, int? bookId)
{
var workspaceStatus = await WritingWorkspaceStatusAsync(userId, new WritingWorkspaceScope
{
ProjectID = projectId,
BookID = bookId
});
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", workspaceStatus);
}
private int RequireUserId() private int RequireUserId()
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use the Word Companion."); => TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use the Word Companion.");
@ -192,7 +260,9 @@ public sealed class WordCompanionFollowHub(
|| state.UsesWordCompanion == true; || state.UsesWordCompanion == true;
private static ManuscriptScanPreview NormalisePreviewContext(ManuscriptScanPreview preview, int userId, UserOnboardingState state) private static ManuscriptScanPreview NormalisePreviewContext(ManuscriptScanPreview preview, int userId, UserOnboardingState state)
=> new() {
var characterCandidates = WordCompanionService.DiscoverManuscriptScanCharacterCandidates(preview);
return new()
{ {
PreviewID = preview.PreviewID, PreviewID = preview.PreviewID,
UserID = preview.UserID == 0 ? userId : preview.UserID, UserID = preview.UserID == 0 ? userId : preview.UserID,
@ -205,10 +275,11 @@ public sealed class WordCompanionFollowHub(
TotalWordCount = preview.TotalWordCount, TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount, ChapterCount = preview.ChapterCount,
SceneCount = preview.SceneCount, SceneCount = preview.SceneCount,
CharacterCandidateCount = preview.CharacterCandidateCount, CharacterCandidateCount = characterCandidates.Count,
CreatedUtc = preview.CreatedUtc, CreatedUtc = preview.CreatedUtc,
Chapters = preview.Chapters, Chapters = preview.Chapters,
Scenes = preview.Scenes, Scenes = preview.Scenes,
CharacterCandidates = preview.CharacterCandidates CharacterCandidates = characterCandidates
}; };
}
} }

View File

@ -0,0 +1,59 @@
using PlotLine.ViewModels;
namespace PlotLine.Models;
public sealed record StoryPosition(int ProjectId, int? BookId = null, int? ChapterId = null, int? SceneId = null)
{
public bool IsValid => ProjectId > 0 && (BookId is null or > 0) && (ChapterId is null or > 0) && (SceneId is null or > 0);
public bool IsScenePosition => SceneId.HasValue;
public static StoryPosition? FromScene(SceneEditViewModel? scene)
{
if (scene?.Project is null)
{
return null;
}
return new StoryPosition(scene.Project.ProjectID, scene.Book?.BookID, scene.ChapterID, scene.SceneID > 0 ? scene.SceneID : null);
}
public static StoryPosition? FromScene(Scene? scene, int projectId)
{
if (scene is null || projectId <= 0)
{
return null;
}
return new StoryPosition(projectId, scene.BookID > 0 ? scene.BookID : null, scene.ChapterID, scene.SceneID);
}
public static StoryPosition? FromRuntimeCurrentScene(WordCompanionRuntimeCurrentSceneRequest request)
{
var position = new StoryPosition(request.ProjectId, request.BookId, request.ChapterId, request.SceneId);
return position.IsValid ? position : null;
}
public static StoryPosition? FromRuntimeCurrentScene(WordCompanionRuntimeCurrentSceneResponse? response)
{
if (response is null)
{
return null;
}
var position = new StoryPosition(response.ProjectId, response.BookId, response.ChapterId, response.SceneId);
return position.IsValid ? position : null;
}
public static StoryPosition? FromTimelineSelection(int projectId, int? bookId, int? chapterId, int? sceneId)
{
if (projectId <= 0)
{
return null;
}
var position = new StoryPosition(projectId, bookId, chapterId, sceneId);
return position.IsValid ? position : null;
}
public string? SceneEditUrl => SceneId.HasValue ? $"/Scenes/Edit/{SceneId.Value}" : null;
}

View File

@ -245,6 +245,7 @@ public sealed class WordCompanionRuntimeCurrentSceneResponse
public int ChapterId { get; init; } public int ChapterId { get; init; }
public int SceneId { get; init; } public int SceneId { get; init; }
public string SceneUrl { get; init; } = string.Empty; public string SceneUrl { get; init; } = string.Empty;
public StoryPosition? Position => StoryPosition.FromRuntimeCurrentScene(this);
} }
public sealed class WordCompanionRuntimeCharacterAliasDto public sealed class WordCompanionRuntimeCharacterAliasDto

View File

@ -0,0 +1,76 @@
namespace PlotLine.Models;
public static class WritingWorkspaceRoles
{
public const string None = "None";
public const string Launcher = "Launcher";
public const string Context = "Context";
public const string Intent = "Intent";
public static string Normalise(string? role)
{
var value = (role ?? string.Empty).Trim();
return value.Equals(Launcher, StringComparison.OrdinalIgnoreCase) ? Launcher
: value.Equals(Context, StringComparison.OrdinalIgnoreCase) ? Context
: value.Equals(Intent, StringComparison.OrdinalIgnoreCase) ? Intent
: None;
}
}
public sealed class WritingWorkspaceScope
{
public int? ProjectID { get; init; }
public int? BookID { get; init; }
}
public sealed class WritingWindowRegistration
{
public string? BrowserWindowID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string? CurrentRoute { get; init; }
public string? WritingRole { get; init; }
public string? DisplayLabel { get; init; }
}
public sealed class WritingWindowHeartbeat
{
public string? BrowserWindowID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string? CurrentRoute { get; init; }
public string? WritingRole { get; init; }
public string? DisplayLabel { get; init; }
}
public sealed class WritingWindowPresence
{
public string BrowserWindowID { get; init; } = string.Empty;
public int UserID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string CurrentRoute { get; init; } = string.Empty;
public string WritingRole { get; init; } = WritingWorkspaceRoles.None;
public string? DisplayLabel { get; init; }
public bool IsActive { get; init; }
public DateTime ConnectedUtc { get; init; }
public DateTime LastSeenUtc { get; init; }
}
public sealed class WritingWorkspaceStatus
{
public int UserID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public bool WordCompanionConnected { get; init; }
public bool WordDocumentOpen { get; init; }
public int? WordProjectID { get; init; }
public int? WordBookID { get; init; }
public StoryPosition? CurrentStoryPosition { get; init; }
public IReadOnlyList<WritingWindowPresence> Windows { get; init; } = [];
public WritingWindowPresence? Launcher { get; init; }
public WritingWindowPresence? Context { get; init; }
public WritingWindowPresence? Intent { get; init; }
public bool ContextOpen => Context?.IsActive == true;
public bool IntentOpen => Intent?.IsActive == true;
}

View File

@ -167,6 +167,7 @@ public class Program
builder.Services.AddScoped<IChapterService, ChapterService>(); builder.Services.AddScoped<IChapterService, ChapterService>();
builder.Services.AddScoped<ISceneService, SceneService>(); builder.Services.AddScoped<ISceneService, SceneService>();
builder.Services.AddScoped<IStoryStateService, StoryStateService>(); builder.Services.AddScoped<IStoryStateService, StoryStateService>();
builder.Services.AddScoped<IStoryContextService, StoryContextService>();
builder.Services.AddScoped<ITimelineService, TimelineService>(); builder.Services.AddScoped<ITimelineService, TimelineService>();
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>(); builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>(); builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>();
@ -194,6 +195,7 @@ public class Program
builder.Services.AddScoped<IImportService, ImportService>(); builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<IWordCompanionService, WordCompanionService>(); builder.Services.AddScoped<IWordCompanionService, WordCompanionService>();
builder.Services.AddSingleton<IWordCompanionPresenceService, WordCompanionPresenceService>(); builder.Services.AddSingleton<IWordCompanionPresenceService, WordCompanionPresenceService>();
builder.Services.AddSingleton<IWritingWorkspacePresenceService, WritingWorkspacePresenceService>();
builder.Services.AddSingleton<IManuscriptScanPreviewStore, ManuscriptScanPreviewStore>(); builder.Services.AddSingleton<IManuscriptScanPreviewStore, ManuscriptScanPreviewStore>();
builder.Services.AddSingleton<IOnboardingStoryIntelligenceBatchStore, OnboardingStoryIntelligenceBatchStore>(); builder.Services.AddSingleton<IOnboardingStoryIntelligenceBatchStore, OnboardingStoryIntelligenceBatchStore>();
builder.Services.AddHostedService<WordCompanionPresenceMonitor>(); builder.Services.AddHostedService<WordCompanionPresenceMonitor>();

View File

@ -146,6 +146,23 @@ public interface IStoryStateService
Task<List<ContinuityWarningAcknowledgement>> GetAcknowledgementsAsync(int projectId); Task<List<ContinuityWarningAcknowledgement>> GetAcknowledgementsAsync(int projectId);
} }
public interface IStoryContextService
{
Task<StoryContextViewModel?> GetAsync(StoryPosition position);
Task<StoryContextViewModel?> GetForSceneAsync(int sceneId, int? projectId = null, int? bookId = null);
}
public sealed class StoryContextViewModel
{
public StoryPosition Position { get; init; } = new(0);
public Project? Project { get; init; }
public Book? Book { get; init; }
public Chapter? Chapter { get; init; }
public SceneEditViewModel? Scene { get; init; }
public StoryStateViewModel StoryState { get; init; } = new();
public string? CanonicalUrl => Position.SceneEditUrl;
}
public interface IPlotService public interface IPlotService
{ {
Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId); Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId);
@ -313,6 +330,7 @@ public interface IStoryBibleService
public interface IRelationshipMapService public interface IRelationshipMapService
{ {
Task<RelationshipMapViewModel?> GetAsync(StoryPosition position, int? focusCharacterId, bool showFullNetwork);
Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork); Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork);
} }
@ -2017,12 +2035,69 @@ public sealed class SceneService(
} }
} }
public sealed class StoryContextService(ISceneService scenes) : IStoryContextService
{
public async Task<StoryContextViewModel?> GetAsync(StoryPosition position)
{
if (!position.IsValid)
{
return null;
}
if (position.SceneId.HasValue)
{
return await GetForSceneAsync(position.SceneId.Value, position.ProjectId, position.BookId);
}
return new StoryContextViewModel
{
Position = position
};
}
public async Task<StoryContextViewModel?> GetForSceneAsync(int sceneId, int? projectId = null, int? bookId = null)
{
if (sceneId <= 0)
{
return null;
}
var scene = await scenes.GetEditAsync(sceneId);
var position = StoryPosition.FromScene(scene);
if (position is null)
{
return null;
}
if (projectId.HasValue && projectId.Value != position.ProjectId)
{
return null;
}
if (bookId.HasValue && position.BookId.HasValue && bookId.Value != position.BookId.Value)
{
return null;
}
return new StoryContextViewModel
{
Position = position,
Project = scene!.Project,
Book = scene.Book,
Chapter = scene.Chapter,
Scene = scene,
StoryState = scene.StoryState
};
}
}
public sealed class TimelineService( public sealed class TimelineService(
ITimelineRepository timelineRepository, ITimelineRepository timelineRepository,
ITimelinePresetRepository presets, ITimelinePresetRepository presets,
ITimelineSettingsRepository timelineSettings, ITimelineSettingsRepository timelineSettings,
IProjectRepository projects, IProjectRepository projects,
IBookRepository books, IBookRepository books,
IStoryContextService storyContext,
ISceneService scenes, ISceneService scenes,
IAssetRepository assets, IAssetRepository assets,
ICharacterRepository characters, ICharacterRepository characters,
@ -2141,9 +2216,10 @@ public sealed class TimelineService(
var characterLanes = settings.ShowCharacterAppearances var characterLanes = settings.ShowCharacterAppearances
? await TimeTimelineLoadAsync("TimelineService.BuildCharacterLanes", () => Task.FromResult(BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes)), projectId, bookId) ? await TimeTimelineLoadAsync("TimelineService.BuildCharacterLanes", () => Task.FromResult(BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes)), projectId, bookId)
: []; : [];
var selectedScene = selectedSceneId.HasValue var selectedContext = selectedSceneId.HasValue
? await TimeTimelineLoadAsync("SceneService.GetEditAsync", () => scenes.GetEditAsync(selectedSceneId.Value), projectId, bookId) ? await TimeTimelineLoadAsync("StoryContextService.GetForSceneAsync", () => storyContext.GetForSceneAsync(selectedSceneId.Value, projectId, bookId), projectId, bookId)
: null; : null;
var selectedScene = selectedContext?.Scene;
if (selectedScene is not null) if (selectedScene is not null)
{ {
selectedScene.ReturnProjectID = projectId; selectedScene.ReturnProjectID = projectId;
@ -5340,6 +5416,13 @@ public sealed class StoryBibleService(IProjectRepository projects, IStoryBibleRe
public sealed class RelationshipMapService(IProjectRepository projects, ICharacterRepository characters) : IRelationshipMapService public sealed class RelationshipMapService(IProjectRepository projects, ICharacterRepository characters) : IRelationshipMapService
{ {
public Task<RelationshipMapViewModel?> GetAsync(StoryPosition position, int? focusCharacterId, bool showFullNetwork)
{
return !position.IsValid
? Task.FromResult<RelationshipMapViewModel?>(null)
: GetAsync(position.ProjectId, position.BookId, position.ChapterId, position.SceneId, focusCharacterId, showFullNetwork);
}
public async Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork) public async Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork)
{ {
if (projectId <= 0) if (projectId <= 0)

View File

@ -1,10 +1,12 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using PlotLine.Hubs; using PlotLine.Hubs;
using PlotLine.Models;
namespace PlotLine.Services; namespace PlotLine.Services;
public sealed class WordCompanionPresenceMonitor( public sealed class WordCompanionPresenceMonitor(
IWordCompanionPresenceService presence, IWordCompanionPresenceService presence,
IWritingWorkspacePresenceService writingPresence,
IManuscriptScanPreviewStore scanStore, IManuscriptScanPreviewStore scanStore,
IHubContext<WordCompanionFollowHub> hub) : BackgroundService IHubContext<WordCompanionFollowHub> hub) : BackgroundService
{ {
@ -21,6 +23,14 @@ public sealed class WordCompanionPresenceMonitor(
await hub.Clients await hub.Clients
.Group(PresenceGroup(status.UserID)) .Group(PresenceGroup(status.UserID))
.SendAsync("WordCompanionPresenceChanged", status, stoppingToken); .SendAsync("WordCompanionPresenceChanged", status, stoppingToken);
var workspaceStatus = await writingPresence.GetStatusAsync(status.UserID, new WritingWorkspaceScope
{
ProjectID = status.LinkedProjectID,
BookID = status.LinkedBookID
}, status);
await hub.Clients
.Group(PresenceGroup(status.UserID))
.SendAsync("WritingWorkspacePresenceChanged", workspaceStatus, stoppingToken);
var failedScans = await scanStore.FailRunningForUserAsync( var failedScans = await scanStore.FailRunningForUserAsync(
status.UserID, status.UserID,
"The Word Companion disconnected before the scan finished. Reopen Word and try again."); "The Word Companion disconnected before the scan finished. Reopen Word and try again.");
@ -29,10 +39,18 @@ public sealed class WordCompanionPresenceMonitor(
await hub.Clients await hub.Clients
.Group(PresenceGroup(status.UserID)) .Group(PresenceGroup(status.UserID))
.SendAsync("OnboardingScanFailed", failedScan, stoppingToken); .SendAsync("OnboardingScanFailed", failedScan, stoppingToken);
}
} }
} }
var staleWorkspaceStatuses = await writingPresence.MarkStaleOfflineAsync(presence.GetStatusAsync);
foreach (var workspaceStatus in staleWorkspaceStatuses)
{
await hub.Clients
.Group(PresenceGroup(workspaceStatus.UserID))
.SendAsync("WritingWorkspacePresenceChanged", workspaceStatus, stoppingToken);
}
} }
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
} }

View File

@ -646,6 +646,32 @@ public sealed class WordCompanionService(
return visible; return visible;
} }
public static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> DiscoverManuscriptScanCharacterCandidates(ManuscriptScanPreview preview)
{
var manuscriptText = string.Join("\n\n", preview.Chapters
.OrderBy(chapter => chapter.ChapterNumber)
.Select(chapter => chapter.ChapterText)
.Where(text => !string.IsNullOrWhiteSpace(text)));
if (string.IsNullOrWhiteSpace(manuscriptText))
{
return FilterIncomingScanCharacterCandidates(preview.CharacterCandidates);
}
return DiscoverCharacterCandidates(manuscriptText)
.Select((candidate, index) => new ManuscriptScanCharacterCandidatePreview
{
TemporaryCharacterKey = $"c-{StableCandidateKey(candidate.Text, index)}",
Name = candidate.Text,
MentionCount = candidate.MentionCount,
QualityScore = candidate.QualityScore,
Category = candidate.Category,
Reason = candidate.Reason,
IsExistingCharacterMatch = candidate.IsExistingCharacterMatch,
SuggestedImportance = candidate.Confidence
})
.ToList();
}
private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence) private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence)
{ {
if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200) if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200)
@ -667,6 +693,8 @@ public sealed class WordCompanionService(
var hasLocationIndicator = words.Any(word => CharacterDiscoveryLocationIndicators.Contains(TrimHonorificPunctuation(word))); var hasLocationIndicator = words.Any(word => CharacterDiscoveryLocationIndicators.Contains(TrimHonorificPunctuation(word)));
var hasStopWord = words.Any(word => CharacterDiscoveryStopWords.Contains(TrimHonorificPunctuation(word))); var hasStopWord = words.Any(word => CharacterDiscoveryStopWords.Contains(TrimHonorificPunctuation(word)));
var isRelationshipTitle = words.Length == 1 && CharacterDiscoveryRelationshipTitles.Contains(first); var isRelationshipTitle = words.Length == 1 && CharacterDiscoveryRelationshipTitles.Contains(first);
var isSingleWord = words.Length == 1;
var hasKnownGivenName = CharacterSexHintService.Suggest(first) != CharacterSexHintService.Unknown;
var nonSentenceStartMentions = evidence.MentionCount - evidence.SentenceStartMentions; var nonSentenceStartMentions = evidence.MentionCount - evidence.SentenceStartMentions;
var mostlySentenceStart = evidence.MentionCount > 0 && evidence.SentenceStartMentions >= Math.Ceiling(evidence.MentionCount * 0.8m); var mostlySentenceStart = evidence.MentionCount > 0 && evidence.SentenceStartMentions >= Math.Ceiling(evidence.MentionCount * 0.8m);
@ -707,10 +735,19 @@ public sealed class WordCompanionService(
{ {
score += 2; score += 2;
} }
if (hasNameLikeWord) if (hasNameLikeWord || hasKnownGivenName)
{ {
score += 3; score += 3;
} }
if (isSingleWord
&& mostlySentenceStart
&& !hasKnownGivenName
&& !hasNameLikeWord
&& evidence.PossessiveEvidenceCount == 0
&& nonSentenceStartMentions == 0)
{
score -= 5;
}
if (hasTemporalWord) if (hasTemporalWord)
{ {
score -= 1; score -= 1;
@ -740,7 +777,7 @@ public sealed class WordCompanionService(
score -= 3; score -= 3;
} }
var category = CandidateCategory(score, hasStopWord, isRelationshipTitle, hasTemporalWord, hasLocationIndicator, evidence); var category = CandidateCategory(score, hasStopWord, isRelationshipTitle, hasTemporalWord, hasLocationIndicator, evidence, isSingleWord, mostlySentenceStart, hasKnownGivenName || hasNameLikeWord, hasHonorific);
var confidence = score >= 4 ? "High" : score >= 2 ? "Medium" : "Low"; var confidence = score >= 4 ? "High" : score >= 2 ? "Medium" : "Low";
if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific) if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific)
{ {
@ -768,12 +805,24 @@ public sealed class WordCompanionService(
bool isRelationshipTitle, bool isRelationshipTitle,
bool hasTemporalWord, bool hasTemporalWord,
bool hasLocationIndicator, bool hasLocationIndicator,
CharacterCandidateEvidence evidence) CharacterCandidateEvidence evidence,
bool isSingleWord,
bool mostlySentenceStart,
bool hasNameEvidence,
bool hasHonorific)
{ {
if (hasStopWord) if (hasStopWord)
{ {
return "Excluded"; return "Excluded";
} }
if (isSingleWord
&& mostlySentenceStart
&& !hasNameEvidence
&& evidence.PossessiveEvidenceCount == 0
&& evidence.MentionCount - evidence.SentenceStartMentions == 0)
{
return "Excluded";
}
if (isRelationshipTitle) if (isRelationshipTitle)
{ {
return "RelationshipTitle"; return "RelationshipTitle";
@ -786,7 +835,10 @@ public sealed class WordCompanionService(
{ {
return "Excluded"; return "Excluded";
} }
if (evidence.MentionCount < CharacterDiscoveryMinimumMentions && evidence.DialogueEvidenceCount == 0) if (evidence.MentionCount < CharacterDiscoveryMinimumMentions
&& evidence.DialogueEvidenceCount == 0
&& !hasHonorific
&& !(hasNameEvidence && (evidence.HumanActionEvidenceCount > 0 || evidence.PossessiveEvidenceCount > 0)))
{ {
return "Excluded"; return "Excluded";
} }
@ -929,6 +981,29 @@ public sealed class WordCompanionService(
return Regex.Replace(NormalizeApostrophes(candidate), @"\s+", " ").Trim().Trim(',', '.', ';', ':', '!', '?', '"', '\''); return Regex.Replace(NormalizeApostrophes(candidate), @"\s+", " ").Trim().Trim(',', '.', ';', ':', '!', '?', '"', '\'');
} }
private static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> FilterIncomingScanCharacterCandidates(
IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates)
=> candidates
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Name))
.Where(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.Where(candidate =>
candidate.Name.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length > 1
|| candidate.QualityScore >= 4
|| CharacterSexHintService.Suggest(candidate.Name) != CharacterSexHintService.Unknown)
.GroupBy(candidate => NormalizeCandidateName(candidate.Name), StringComparer.OrdinalIgnoreCase)
.Select(group => group.OrderByDescending(candidate => candidate.QualityScore).ThenByDescending(candidate => candidate.MentionCount).First())
.OrderByDescending(candidate => CategorySortValue(candidate.Category))
.ThenByDescending(candidate => candidate.QualityScore)
.ThenByDescending(candidate => candidate.MentionCount)
.ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
private static string StableCandidateKey(string name, int index)
{
var slug = Regex.Replace(name.ToLowerInvariant(), @"[^a-z0-9]+", "-").Trim('-');
return string.IsNullOrWhiteSpace(slug) ? $"candidate-{index + 1}" : slug;
}
private static string NormalizeApostrophes(string? value) private static string NormalizeApostrophes(string? value)
{ {
return (value ?? string.Empty).Replace('’', '\'').Replace('‘', '\''); return (value ?? string.Empty).Replace('’', '\'').Replace('‘', '\'');

View File

@ -0,0 +1,217 @@
using System.Collections.Concurrent;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IWritingWorkspacePresenceService
{
Task<WritingWorkspaceStatus> RegisterAsync(int userId, string connectionId, WritingWindowRegistration registration, WordCompanionPresenceStatus wordStatus);
Task<WritingWorkspaceStatus> HeartbeatAsync(int userId, string connectionId, WritingWindowHeartbeat heartbeat, WordCompanionPresenceStatus wordStatus);
Task<WritingWorkspaceStatus?> DisconnectAsync(string connectionId, WordCompanionPresenceStatus? wordStatus);
Task<WritingWorkspaceStatus> GetStatusAsync(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus);
Task<IReadOnlyList<WritingWorkspaceStatus>> MarkStaleOfflineAsync(Func<int, Task<WordCompanionPresenceStatus>> wordStatusForUser);
Task<string?> GetConnectionIdForRoleAsync(int userId, WritingWorkspaceScope scope, string role);
}
public sealed class WritingWorkspacePresenceService : IWritingWorkspacePresenceService
{
private static readonly TimeSpan ActiveTimeout = TimeSpan.FromSeconds(45);
private readonly ConcurrentDictionary<string, WindowRecord> windowsById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, string> windowIdsByConnection = new(StringComparer.Ordinal);
public Task<WritingWorkspaceStatus> RegisterAsync(int userId, string connectionId, WritingWindowRegistration registration, WordCompanionPresenceStatus wordStatus)
{
var now = DateTime.UtcNow;
var windowId = Clean(registration.BrowserWindowID);
if (windowId is null)
{
windowId = Guid.NewGuid().ToString("N");
}
var record = windowsById.AddOrUpdate(
WindowKey(userId, windowId),
_ => new WindowRecord
{
BrowserWindowID = windowId,
UserID = userId,
ConnectedUtc = now
},
(_, existing) => existing);
Apply(record, connectionId, registration.ProjectID, registration.BookID, registration.CurrentRoute, registration.WritingRole, registration.DisplayLabel, now);
windowIdsByConnection[connectionId] = WindowKey(userId, windowId);
return Task.FromResult(BuildStatus(userId, new WritingWorkspaceScope { ProjectID = record.ProjectID, BookID = record.BookID }, wordStatus));
}
public Task<WritingWorkspaceStatus> HeartbeatAsync(int userId, string connectionId, WritingWindowHeartbeat heartbeat, WordCompanionPresenceStatus wordStatus)
{
var registration = new WritingWindowRegistration
{
BrowserWindowID = heartbeat.BrowserWindowID,
ProjectID = heartbeat.ProjectID,
BookID = heartbeat.BookID,
CurrentRoute = heartbeat.CurrentRoute,
WritingRole = heartbeat.WritingRole,
DisplayLabel = heartbeat.DisplayLabel
};
return RegisterAsync(userId, connectionId, registration, wordStatus);
}
public Task<WritingWorkspaceStatus?> DisconnectAsync(string connectionId, WordCompanionPresenceStatus? wordStatus)
{
if (!windowIdsByConnection.TryRemove(connectionId, out var key)
|| !windowsById.TryGetValue(key, out var record)
|| !string.Equals(record.ConnectionID, connectionId, StringComparison.Ordinal))
{
return Task.FromResult<WritingWorkspaceStatus?>(null);
}
record.IsActive = false;
record.ConnectionID = string.Empty;
var status = BuildStatus(record.UserID, new WritingWorkspaceScope { ProjectID = record.ProjectID, BookID = record.BookID }, wordStatus ?? new WordCompanionPresenceStatus { UserID = record.UserID });
return Task.FromResult<WritingWorkspaceStatus?>(status);
}
public Task<WritingWorkspaceStatus> GetStatusAsync(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus)
=> Task.FromResult(BuildStatus(userId, scope, wordStatus));
public async Task<IReadOnlyList<WritingWorkspaceStatus>> MarkStaleOfflineAsync(Func<int, Task<WordCompanionPresenceStatus>> wordStatusForUser)
{
var now = DateTime.UtcNow;
var changedScopes = new Dictionary<string, (int UserID, int? ProjectID, int? BookID)>(StringComparer.Ordinal);
foreach (var record in windowsById.Values)
{
if (!record.IsActive || now - record.LastSeenUtc <= ActiveTimeout)
{
continue;
}
record.IsActive = false;
if (!string.IsNullOrWhiteSpace(record.ConnectionID))
{
windowIdsByConnection.TryRemove(record.ConnectionID, out _);
record.ConnectionID = string.Empty;
}
changedScopes[ScopeKey(record.UserID, record.ProjectID, record.BookID)] = (record.UserID, record.ProjectID, record.BookID);
}
var statuses = new List<WritingWorkspaceStatus>();
foreach (var scope in changedScopes.Values)
{
var wordStatus = await wordStatusForUser(scope.UserID);
statuses.Add(BuildStatus(scope.UserID, new WritingWorkspaceScope { ProjectID = scope.ProjectID, BookID = scope.BookID }, wordStatus));
}
return statuses;
}
public Task<string?> GetConnectionIdForRoleAsync(int userId, WritingWorkspaceScope scope, string role)
{
var normalRole = WritingWorkspaceRoles.Normalise(role);
var now = DateTime.UtcNow;
var record = ActiveWindows(userId, scope, now)
.Where(window => string.Equals(window.WritingRole, normalRole, StringComparison.Ordinal))
.OrderByDescending(window => window.LastSeenUtc)
.FirstOrDefault();
return Task.FromResult(record?.ConnectionID);
}
private static void Apply(WindowRecord record, string connectionId, int? projectId, int? bookId, string? route, string? role, string? displayLabel, DateTime now)
{
record.ConnectionID = connectionId;
record.ProjectID = Positive(projectId);
record.BookID = Positive(bookId);
record.CurrentRoute = Clean(route) ?? "/";
record.WritingRole = WritingWorkspaceRoles.Normalise(role);
record.DisplayLabel = Clean(displayLabel);
record.IsActive = true;
record.LastSeenUtc = now;
}
private WritingWorkspaceStatus BuildStatus(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus)
{
var now = DateTime.UtcNow;
var windows = ActiveWindows(userId, scope, now)
.Select(ToPresence)
.OrderBy(window => window.WritingRole)
.ThenByDescending(window => window.LastSeenUtc)
.ToList();
return new WritingWorkspaceStatus
{
UserID = userId,
ProjectID = Positive(scope.ProjectID) ?? Positive(wordStatus.LinkedProjectID),
BookID = Positive(scope.BookID) ?? Positive(wordStatus.LinkedBookID),
WordCompanionConnected = wordStatus.IsConnected,
WordDocumentOpen = wordStatus.DocumentOpen,
WordProjectID = wordStatus.LinkedProjectID,
WordBookID = wordStatus.LinkedBookID,
Windows = windows,
Launcher = LatestRole(windows, WritingWorkspaceRoles.Launcher),
Context = LatestRole(windows, WritingWorkspaceRoles.Context),
Intent = LatestRole(windows, WritingWorkspaceRoles.Intent)
};
}
private IEnumerable<WindowRecord> ActiveWindows(int userId, WritingWorkspaceScope scope, DateTime now)
{
var projectId = Positive(scope.ProjectID);
var bookId = Positive(scope.BookID);
return windowsById.Values.Where(record =>
record.UserID == userId
&& record.IsActive
&& now - record.LastSeenUtc <= ActiveTimeout
&& (!projectId.HasValue || record.ProjectID == projectId)
&& (!bookId.HasValue || record.BookID == bookId));
}
private static WritingWindowPresence? LatestRole(IReadOnlyList<WritingWindowPresence> windows, string role)
=> windows
.Where(window => string.Equals(window.WritingRole, role, StringComparison.Ordinal))
.OrderByDescending(window => window.LastSeenUtc)
.FirstOrDefault();
private static WritingWindowPresence ToPresence(WindowRecord record)
=> new()
{
BrowserWindowID = record.BrowserWindowID,
UserID = record.UserID,
ProjectID = record.ProjectID,
BookID = record.BookID,
CurrentRoute = record.CurrentRoute,
WritingRole = record.WritingRole,
DisplayLabel = record.DisplayLabel,
IsActive = record.IsActive,
ConnectedUtc = record.ConnectedUtc,
LastSeenUtc = record.LastSeenUtc
};
private static string? Clean(string? value)
{
var cleaned = (value ?? string.Empty).Trim();
return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned;
}
private static int? Positive(int? value)
=> value is > 0 ? value.Value : null;
private static string WindowKey(int userId, string windowId) => $"{userId}:{windowId}";
private static string ScopeKey(int userId, int? projectId, int? bookId) => $"{userId}:{projectId ?? 0}:{bookId ?? 0}";
private sealed class WindowRecord
{
public string BrowserWindowID { get; init; } = string.Empty;
public int UserID { get; init; }
public string ConnectionID { get; set; } = string.Empty;
public int? ProjectID { get; set; }
public int? BookID { get; set; }
public string CurrentRoute { get; set; } = "/";
public string WritingRole { get; set; } = WritingWorkspaceRoles.None;
public string? DisplayLabel { get; set; }
public bool IsActive { get; set; }
public DateTime ConnectedUtc { get; init; }
public DateTime LastSeenUtc { get; set; }
}
}

View File

@ -1,52 +0,0 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Runtime_CurrentScene_Validate
@UserID int,
@DocumentGuid uniqueidentifier,
@ProjectID int,
@BookID int,
@ChapterID int,
@SceneID int
AS
BEGIN
SET NOCOUNT ON;
IF @DocumentGuid IS NULL
OR @DocumentGuid = '00000000-0000-0000-0000-000000000000'
OR @ProjectID <= 0
OR @BookID <= 0
OR @ChapterID <= 0
OR @SceneID <= 0
RETURN;
SELECT p.ProjectID AS ProjectId,
b.BookID AS BookId,
c.ChapterID AS ChapterId,
s.SceneID AS SceneId,
CONCAT(N'/Scenes/Details/', s.SceneID) AS SceneUrl
FROM dbo.Projects p
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
INNER JOIN dbo.Books b ON b.ProjectID = p.ProjectID
INNER JOIN dbo.Chapters c ON c.BookID = b.BookID
INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID
INNER JOIN dbo.ManuscriptDocuments md ON md.ProjectID = p.ProjectID
AND md.BookID = b.BookID
WHERE p.ProjectID = @ProjectID
AND b.BookID = @BookID
AND c.ChapterID = @ChapterID
AND s.SceneID = @SceneID
AND md.DocumentGuid = @DocumentGuid
AND md.IsActive = 1
AND s.IsLinkedToManuscript = 1
AND s.ManuscriptDocumentID = md.ManuscriptDocumentID
AND p.IsArchived = 0
AND b.IsArchived = 0
AND c.IsArchived = 0
AND s.IsArchived = 0
AND pua.UserID = @UserID
AND pua.IsActive = 1;
END;
GO

View File

@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Models; using PlotLine.Models;
using PlotLine.Services;
namespace PlotLine.ViewModels; namespace PlotLine.ViewModels;
@ -416,6 +417,7 @@ public sealed class SceneEditViewModel
public int? ReturnProjectID { get; set; } public int? ReturnProjectID { get; set; }
public int? ReturnBookID { get; set; } public int? ReturnBookID { get; set; }
public bool ReturnToTimeline { get; set; } public bool ReturnToTimeline { get; set; }
public StoryPosition? Position => StoryPosition.FromScene(this);
public Chapter? Chapter { get; set; } public Chapter? Chapter { get; set; }
public Book? Book { get; set; } public Book? Book { get; set; }
@ -427,6 +429,45 @@ public sealed class SceneEditViewModel
public IReadOnlyList<ScenePurposeType> ScenePurposeTypes { get; set; } = []; public IReadOnlyList<ScenePurposeType> ScenePurposeTypes { get; set; } = [];
} }
public sealed class WritingContextPageViewModel
{
public int? ProjectID { get; set; }
public int? BookID { get; set; }
public int? ChapterID { get; set; }
public int? SceneID { get; set; }
public WritingContextDisplayViewModel? Display { get; set; }
}
public sealed class WritingContextDisplayViewModel
{
public StoryContextViewModel? Context { get; set; }
public RelationshipMapViewModel? RelationshipMap { get; set; }
public FloorPlanEditorViewModel? FloorPlan { get; set; }
public string FollowStatus { get; set; } = "Connected, waiting for scene";
public IReadOnlyList<SelectListItem> SceneOptions { get; set; } = [];
public bool HasScene => Context?.Scene is not null;
}
public sealed class WritingIntentPageViewModel
{
public int? ProjectID { get; set; }
public int? BookID { get; set; }
public int? ChapterID { get; set; }
public int? SceneID { get; set; }
public WritingIntentDisplayViewModel? Display { get; set; }
}
public sealed class WritingIntentDisplayViewModel
{
public StoryContextViewModel? Context { get; set; }
public ChapterWorkflowViewModel? ChapterWorkflow { get; set; }
public string FollowStatus { get; set; } = "Connected, waiting for scene";
public IReadOnlyList<SelectListItem> SceneOptions { get; set; } = [];
public bool HasScene => Context?.Scene is not null;
public bool HasChapter => ChapterWorkflow?.Chapter is not null || Context?.Chapter is not null;
public bool IsChapterOnly => !HasScene && HasChapter;
}
public sealed class SceneDetailsSaveViewModel public sealed class SceneDetailsSaveViewModel
{ {
public int SceneID { get; set; } public int SceneID { get; set; }

View File

@ -1170,6 +1170,23 @@
refreshOccupancyTrays(); refreshOccupancyTrays();
} }
function applyStoryPosition(position) {
if (!occupancySceneSelect || !position?.sceneId) {
return;
}
const nextSceneId = String(position.sceneId);
const option = [...occupancySceneSelect.options].find(item => item.value === nextSceneId);
if (!option) {
return;
}
occupancySceneSelect.value = nextSceneId;
setFloorPlanMode("occupancy");
applyOccupancySceneFloor();
refreshOccupancyTrays();
}
function applyOccupancySceneFloor() { function applyOccupancySceneFloor() {
const option = occupancySceneSelect?.selectedOptions?.[0]; const option = occupancySceneSelect?.selectedOptions?.[0];
const initialFloorId = option?.dataset.initialFloorId; const initialFloorId = option?.dataset.initialFloorId;
@ -2429,6 +2446,10 @@
toolboxTabs.forEach(tab => tab.addEventListener("click", () => setToolboxTab(tab.dataset.toolboxTab))); toolboxTabs.forEach(tab => tab.addEventListener("click", () => setToolboxTab(tab.dataset.toolboxTab)));
modeButtons.forEach(button => button.addEventListener("click", () => setFloorPlanMode(button.dataset.floorPlanMode))); modeButtons.forEach(button => button.addEventListener("click", () => setFloorPlanMode(button.dataset.floorPlanMode)));
occupancySceneSelect?.addEventListener("change", () => { occupancySceneSelect?.addEventListener("change", () => {
window.PlotDirectorStoryPosition?.publishChanged({
projectId: @Model.Project.ProjectID,
sceneId: selectedOccupancySceneId()
}, "FloorPlan");
applyOccupancySceneFloor(); applyOccupancySceneFloor();
refreshOccupancyTrays(); refreshOccupancyTrays();
}); });
@ -2467,6 +2488,9 @@
}); });
addBlockForm?.addEventListener("submit", addBlockAsync); addBlockForm?.addEventListener("submit", addBlockAsync);
editor.addEventListener("floor-plan-restore-state", event => restoreWorkspaceState(event.detail || {})); editor.addEventListener("floor-plan-restore-state", event => restoreWorkspaceState(event.detail || {}));
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", event => {
applyStoryPosition(event.detail);
});
workspace?.addEventListener("submit", async event => { workspace?.addEventListener("submit", async event => {
if (event.defaultPrevented) return; if (event.defaultPrevented) return;
const form = event.target; const form = event.target;

View File

@ -101,6 +101,17 @@
<p>Linked to PlotDirector</p> <p>Linked to PlotDirector</p>
</section> </section>
<section class="word-companion-section word-companion-writing-workspace" aria-label="Writing workspace">
<div class="word-companion-section-header">
<span>Writing Workspace</span>
</div>
<div class="word-companion-actions">
<button type="button" data-open-writing-workspace>Open Workspace</button>
<button type="button" data-open-writing-context>Open Context</button>
<button type="button" data-open-writing-intent>Open Intent</button>
</div>
</section>
<nav class="word-companion-tabs" aria-label="Companion sections"> <nav class="word-companion-tabs" aria-label="Companion sections">
<button type="button" class="is-active" data-tab-button="scene" aria-selected="true">Scene</button> <button type="button" class="is-active" data-tab-button="scene" aria-selected="true">Scene</button>
<button type="button" data-tab-button="review" aria-selected="false">Review</button> <button type="button" data-tab-button="review" aria-selected="false">Review</button>
@ -270,10 +281,28 @@
<span>Manuscript Maintenance</span> <span>Manuscript Maintenance</span>
</div> </div>
<p class="word-companion-message">Use these tools when manuscript headings, scene boundaries, or document links need attention.</p> <p class="word-companion-message">Use these tools when manuscript headings, scene boundaries, or document links need attention.</p>
<div class="word-companion-marker-status" data-marker-status>
<div><span>Document binding</span><strong data-marker-binding-status>Unknown</strong></div>
<div><span>Chapter markers</span><strong data-marker-chapter-count>-</strong></div>
<div><span>Scene markers</span><strong data-marker-scene-count>-</strong></div>
</div>
<div class="word-companion-maintenance-group">
<div>
<strong>Unlink from PlotDirector</strong>
<p>Disconnect this document while leaving hidden PlotDirector anchors in place for possible reuse.</p>
</div>
<button type="button" class="word-companion-danger-action" data-unlink-word-document hidden>Unlink from PlotDirector</button>
</div>
<div class="word-companion-maintenance-group word-companion-maintenance-group-strong">
<div>
<strong>Clean and unlink document</strong>
<p>Remove PlotDirector hidden anchors and binding metadata from this Word document while preserving manuscript text.</p>
</div>
<button type="button" class="word-companion-danger-action" data-clean-unlink-word-document>Clean and unlink</button>
</div>
<div class="word-companion-actions word-companion-maintenance-actions"> <div class="word-companion-actions word-companion-maintenance-actions">
<button type="button" data-attach-plotdirector-ids disabled>Attach PlotDirector IDs</button> <button type="button" data-attach-plotdirector-ids disabled>Attach PlotDirector IDs</button>
<button type="button" data-refresh-document>Refresh Local Cache</button> <button type="button" data-refresh-document>Refresh Local Cache</button>
<button type="button" class="word-companion-danger-action" data-unlink-word-document hidden>Unlink this Word document</button>
</div> </div>
</section> </section>
<section class="word-companion-section word-companion-structure-preview" aria-label="Structure Sync Preview"> <section class="word-companion-section word-companion-structure-preview" aria-label="Structure Sync Preview">
@ -539,15 +568,38 @@
<div class="word-companion-section-header"> <div class="word-companion-section-header">
<span>Unlink Word Document?</span> <span>Unlink Word Document?</span>
</div> </div>
<p class="word-companion-dialog-copy">This will remove PlotDirector binding metadata from this Word document.</p> <p class="word-companion-dialog-copy">This will remove PlotDirector binding metadata from this Word document and unlink this Book in PlotDirector.</p>
<p class="word-companion-dialog-copy">If PlotDirector is reachable, it will also unlink this Book in PlotDirector.</p> <p class="word-companion-dialog-copy">Hidden PlotDirector anchors will be left in place for possible reuse.</p>
<p class="word-companion-dialog-copy">Your manuscript text will not be changed.</p> <p class="word-companion-dialog-copy">Your manuscript text will not be changed.</p>
<div class="word-companion-marker-status word-companion-dialog-marker-status">
<div><span>Chapter markers kept</span><strong data-unlink-dialog-chapter-count>-</strong></div>
<div><span>Scene markers kept</span><strong data-unlink-dialog-scene-count>-</strong></div>
</div>
<div class="word-companion-dialog-actions"> <div class="word-companion-dialog-actions">
<button type="button" data-unlink-word-document-confirm>Continue</button> <button type="button" data-unlink-word-document-confirm>Continue</button>
<button type="button" data-unlink-word-document-cancel>Cancel</button> <button type="button" data-unlink-word-document-cancel>Cancel</button>
</div> </div>
</form> </form>
</dialog> </dialog>
<dialog class="word-companion-dialog" data-clean-unlink-word-document-dialog>
<form method="dialog">
<div class="word-companion-section-header">
<span>Clean and Unlink?</span>
</div>
<p class="word-companion-dialog-copy">This will remove PlotDirector hidden chapter and scene anchors from this Word document.</p>
<p class="word-companion-dialog-copy">Visible manuscript text, headings, formatting, comments, and tracked changes should remain in the document.</p>
<p class="word-companion-dialog-copy">PlotDirector binding metadata will be removed and the Book will be unlinked in PlotDirector.</p>
<div class="word-companion-marker-status word-companion-dialog-marker-status">
<div><span>Chapter markers removed</span><strong data-clean-dialog-chapter-count>-</strong></div>
<div><span>Scene markers removed</span><strong data-clean-dialog-scene-count>-</strong></div>
</div>
<div class="word-companion-dialog-actions">
<button type="button" data-clean-unlink-word-document-confirm>Clean and unlink</button>
<button type="button" data-clean-unlink-word-document-cancel>Cancel</button>
</div>
</form>
</dialog>
} }
</main> </main>
@if (Model.IsAuthenticated) @if (Model.IsAuthenticated)

View File

@ -3,6 +3,14 @@
ViewData["Title"] = "Writer Workspace"; ViewData["Title"] = "Writer Workspace";
var message = TempData["WriterWorkspaceMessage"] as string; var message = TempData["WriterWorkspaceMessage"] as string;
var error = TempData["WriterWorkspaceError"] as string; var error = TempData["WriterWorkspaceError"] as string;
var workspaceSeed = Model.WriteNext
.Concat(Model.ContinueWriting)
.Concat(Model.RevisionQueue)
.Concat(Model.PolishingQueue)
.FirstOrDefault();
var workspaceProjectId = workspaceSeed?.ProjectID ?? Model.ProjectID;
var workspaceBookId = workspaceSeed?.BookID;
var workspaceBookTitle = workspaceSeed?.BookDisplayTitle ?? "Waiting for Word";
} }
<div class="page-heading writer-dashboard-heading"> <div class="page-heading writer-dashboard-heading">
@ -42,6 +50,35 @@
</div> </div>
} }
<section class="story-bible-section writing-workspace-launcher"
data-writing-workspace-launcher
data-project-id="@workspaceProjectId"
data-book-id="@workspaceBookId">
<div class="story-bible-section-heading">
<div>
<p class="eyebrow">Writing Workspace</p>
<h2>Context | Word | Intent</h2>
</div>
<span class="soft-count" data-writing-workspace-book>@workspaceBookTitle</span>
</div>
<div class="writing-workspace-status-grid" aria-label="Writing workspace status">
<div><span>Word Companion</span><strong data-writing-workspace-word>Checking...</strong></div>
<div><span>Context</span><strong data-writing-workspace-context>Checking...</strong></div>
<div><span>Intent</span><strong data-writing-workspace-intent>Checking...</strong></div>
<div><span>Layout</span><strong data-writing-workspace-layout>Adaptive</strong></div>
</div>
<p class="muted writing-workspace-message" data-writing-workspace-message>
Open a linked Word manuscript or use a queued scene to start the writing workstation.
</p>
<div class="button-row">
<button type="button" class="btn btn-primary" data-start-writing-workspace>Start / Reapply Workspace</button>
<button type="button" class="btn btn-outline-primary" data-open-writing-context>Open Context</button>
<button type="button" class="btn btn-outline-primary" data-open-writing-intent>Open Intent</button>
<button type="button" class="btn btn-outline-secondary" data-focus-writing-context hidden>Focus Context</button>
<button type="button" class="btn btn-outline-secondary" data-focus-writing-intent hidden>Focus Intent</button>
</div>
</section>
<section class="story-bible-section"> <section class="story-bible-section">
<div class="story-bible-section-heading"> <div class="story-bible-section-heading">
<div> <div>
@ -274,3 +311,8 @@
} }
</div> </div>
</section> </section>
@section Scripts {
<script src="~/js/writing-workspace.js" asp-append-version="true"></script>
<script src="~/js/writing-workspace-launcher.js" asp-append-version="true"></script>
}

View File

@ -0,0 +1,45 @@
@model WritingContextPageViewModel
@{
ViewData["Title"] = "Writing Context";
ViewData["ProjectSection"] = "Writer";
}
<main class="writing-context-page"
data-writing-mode="context"
data-writing-context
data-context-url="@Url.Action("ContextScene", "Writing")"
data-scope-project-id="@Model.ProjectID"
data-scope-book-id="@Model.BookID">
<header class="writing-context-shell-header">
<div>
<p class="eyebrow">Writing Context</p>
<h1>Writing Context</h1>
</div>
<div class="writing-context-status" data-writing-context-status>
<span class="writing-context-status-dot" aria-hidden="true"></span>
<span data-writing-context-status-text>@(Model.Display?.FollowStatus ?? "Connected, waiting for scene")</span>
</div>
<div class="writing-workspace-role-indicator">
<span class="writing-context-status-dot" aria-hidden="true"></span>
<span>Writing Workspace</span>
</div>
</header>
<section data-writing-context-content>
<partial name="_WritingContextContent" model="Model.Display ?? new WritingContextDisplayViewModel()" />
</section>
</main>
@section Scripts {
<script src="~/js/writing-workspace.js" asp-append-version="true"></script>
<script src="~/js/writing-follow.js" asp-append-version="true"></script>
<script src="~/js/writing-context.js" asp-append-version="true"></script>
<script>
window.PlotDirectorWritingWorkspace?.registerWindow({
projectID: "@Model.ProjectID",
bookID: "@Model.BookID",
writingRole: window.PlotDirectorWritingWorkspace.roles.context,
displayLabel: "Writing Context"
});
</script>
}

View File

@ -0,0 +1,45 @@
@model WritingIntentPageViewModel
@{
ViewData["Title"] = "Writing Intent";
ViewData["ProjectSection"] = "Writer";
}
<main class="writing-context-page"
data-writing-mode="intent"
data-writing-intent
data-context-url="@Url.Action("IntentScene", "Writing")"
data-scope-project-id="@Model.ProjectID"
data-scope-book-id="@Model.BookID">
<header class="writing-context-shell-header">
<div>
<p class="eyebrow">Writing Intent</p>
<h1>Writing Intent</h1>
</div>
<div class="writing-context-status" data-writing-intent-status>
<span class="writing-context-status-dot" aria-hidden="true"></span>
<span data-writing-intent-status-text>@(Model.Display?.FollowStatus ?? "Connected, waiting for scene")</span>
</div>
<div class="writing-workspace-role-indicator">
<span class="writing-context-status-dot" aria-hidden="true"></span>
<span>Writing Workspace</span>
</div>
</header>
<section data-writing-intent-content>
<partial name="_WritingIntentContent" model="Model.Display ?? new WritingIntentDisplayViewModel()" />
</section>
</main>
@section Scripts {
<script src="~/js/writing-workspace.js" asp-append-version="true"></script>
<script src="~/js/writing-follow.js" asp-append-version="true"></script>
<script src="~/js/writing-intent.js" asp-append-version="true"></script>
<script>
window.PlotDirectorWritingWorkspace?.registerWindow({
projectID: "@Model.ProjectID",
bookID: "@Model.BookID",
writingRole: window.PlotDirectorWritingWorkspace.roles.intent,
displayLabel: "Writing Intent"
});
</script>
}

View File

@ -0,0 +1,74 @@
@model WritingContextDisplayViewModel
@{
var scene = Model.Context?.Scene;
var position = Model.Context?.Position;
}
<div class="writing-context-content"
data-writing-context-fragment
data-project-id="@position?.ProjectId"
data-book-id="@position?.BookId"
data-chapter-id="@position?.ChapterId"
data-scene-id="@position?.SceneId"
data-follow-status="@Model.FollowStatus">
@if (scene is null)
{
<section class="writing-context-empty">
<p class="eyebrow">Waiting</p>
<h2>Waiting for Word...</h2>
<p>Open a linked manuscript or choose a scene manually once context is available.</p>
</section>
}
else
{
<header class="writing-context-scene-header">
<div>
<p class="eyebrow">@scene.Project?.ProjectName</p>
<h2>@scene.SceneTitle</h2>
<p>
@scene.Book?.BookDisplayTitle
<span aria-hidden="true">/</span>
Chapter @scene.Chapter?.ChapterNumber:g
<span aria-hidden="true">/</span>
Scene @scene.SceneNumber:g
</p>
</div>
<div class="writing-context-scene-actions">
<label for="writing-context-scene-select">Jump to scene</label>
<select id="writing-context-scene-select" class="form-select form-select-sm" data-writing-context-scene-select>
@foreach (var option in Model.SceneOptions)
{
<option value="@option.Value" selected="@option.Selected">@option.Text</option>
}
</select>
<a class="btn btn-outline-secondary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Open Scene</a>
</div>
</header>
<nav class="writing-context-tabs" aria-label="Writing context tabs">
<button type="button" class="active" data-writing-context-tab="overview">Overview</button>
<button type="button" data-writing-context-tab="people">People</button>
<button type="button" data-writing-context-tab="relationships">Relationships</button>
<button type="button" data-writing-context-tab="place">Place</button>
<button type="button" data-writing-context-tab="story">Story</button>
</nav>
<div class="writing-context-tab-panels">
<section class="writing-context-tab-panel active" data-writing-context-panel="overview">
<partial name="_WritingContextOverview" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-context-panel="people" hidden>
<partial name="_WritingContextPeople" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-context-panel="relationships" hidden>
<partial name="_WritingContextRelationships" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-context-panel="place" hidden>
<partial name="_WritingContextPlace" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-context-panel="story" hidden>
<partial name="_WritingContextStory" model="Model" />
</section>
</div>
}
</div>

View File

@ -0,0 +1,74 @@
@model WritingContextDisplayViewModel
@{
var scene = Model.Context!.Scene!;
var pov = scene.CharacterOptions.FirstOrDefault(x => x.Value == scene.POVCharacterID?.ToString())?.Text
?? scene.SceneCharacters.FirstOrDefault(x => x.CharacterID == scene.POVCharacterID)?.CharacterName
?? "No POV set";
var primaryLocation = scene.LocationOptions.FirstOrDefault(x => x.Value == scene.PrimaryLocationID?.ToString())?.Text?.Trim()
?? "No primary location";
var purposeLabels = scene.ScenePurposeTypes
.Where(x => scene.SelectedPurposeTypeIds.Contains(x.ScenePurposeTypeID))
.OrderBy(x => x.SortOrder)
.Select(x => x.PurposeName)
.ToList();
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid writing-context-grid--overview">
<article class="writing-context-card writing-context-card--hero">
<p class="eyebrow">Current Scene</p>
<h3>@scene.SceneTitle</h3>
<p>@TextOr(scene.Summary, "No scene summary has been written yet.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Where and When</p>
<dl class="writing-context-facts">
<dt>POV</dt><dd>@pov</dd>
<dt>Date</dt><dd>@(scene.StartDateTime?.ToString("d MMM yyyy") ?? scene.RelativeTimeText ?? "Unknown")</dd>
<dt>Time</dt><dd>@(scene.StartDateTime?.ToString("HH:mm") ?? scene.TimeModeName ?? "Unknown")</dd>
<dt>Location</dt><dd>@primaryLocation</dd>
<dt>Status</dt><dd>@(scene.RevisionStatuses.FirstOrDefault(x => x.Value == scene.RevisionStatusID.ToString())?.Text ?? scene.RevisionStatusID.ToString())</dd>
</dl>
</article>
<article class="writing-context-card">
<p class="eyebrow">Purpose</p>
@if (purposeLabels.Any())
{
<div class="writing-context-chip-row">
@foreach (var purpose in purposeLabels)
{
<span>@purpose</span>
}
</div>
}
else
{
<p>No scene purpose selected.</p>
}
<p>@TextOr(scene.ScenePurposeNotes, "No purpose notes yet.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Outcome</p>
<p>@TextOr(scene.SceneOutcomeNotes, "No outcome notes yet.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Continuity</p>
@if (scene.Warnings.Any() || scene.AgeContinuityWarnings.Any())
{
<ul class="writing-context-list">
@foreach (var warning in scene.Warnings.Concat(scene.AgeContinuityWarnings).Take(4))
{
<li><strong>@warning.SeverityName</strong> @warning.Message</li>
}
</ul>
}
else
{
<p>No continuity warnings.</p>
}
</article>
</div>

View File

@ -0,0 +1,53 @@
@model WritingContextDisplayViewModel
@{
var scene = Model.Context!.Scene!;
var povCharacterId = scene.POVCharacterID;
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
@if (!scene.SceneCharacters.Any())
{
<section class="writing-context-empty">
<h3>No characters recorded for this scene</h3>
<p>When scene participants are added, their current state will appear here.</p>
</section>
}
else
{
<div class="writing-context-people-grid">
@foreach (var character in scene.SceneCharacters.OrderByDescending(x => x.CharacterID == povCharacterId).ThenBy(x => x.CharacterName))
{
var knowledge = scene.CharacterKnowledge.Where(x => x.CharacterID == character.CharacterID).ToList();
var attributes = scene.CharacterAttributeEvents.Where(x => x.CharacterID == character.CharacterID).ToList();
<article class="writing-context-card writing-context-person-card">
<div class="writing-context-person-heading">
<partial name="~/Views/Shared/_Avatar.cshtml" model="@(new AvatarViewModel { DisplayName = character.CharacterName, ImagePath = character.CharacterImagePath, ThumbnailPath = character.CharacterThumbnailPath, Size = "md" })" />
<div>
<h3>@character.CharacterName</h3>
<p>@(character.CharacterID == povCharacterId ? "POV" : character.RoleInSceneTypeName ?? character.PresenceTypeName ?? "In scene")</p>
</div>
</div>
<dl class="writing-context-facts">
<dt>Location</dt><dd>@TextOr(character.LocationPath ?? character.LocationName, "Unknown")</dd>
<dt>Emotional state</dt><dd>@TextOr(character.EmotionalState, "Not recorded")</dd>
<dt>Physical condition</dt><dd>@TextOr(character.PhysicalCondition, "Not recorded")</dd>
<dt>Outfit</dt><dd>@TextOr(character.OutfitDescription ?? character.AppearanceNotes, "Not recorded")</dd>
<dt>Movement</dt><dd>@TextOr(string.Join(" -> ", new[] { character.EntryLocationName, character.ExitLocationName }.Where(x => !string.IsNullOrWhiteSpace(x))), "No entry or exit recorded")</dd>
</dl>
@if (knowledge.Any() || attributes.Any())
{
<div class="writing-context-mini-section">
@foreach (var item in knowledge.Take(3))
{
<p><strong>@item.KnowledgeStateName:</strong> @TextOr(item.Description, item.AssetName ?? item.ThreadTitle ?? "Knowledge change recorded.")</p>
}
@foreach (var item in attributes.Take(3))
{
<p><strong>@item.AttributeName:</strong> @item.AttributeValue</p>
}
</div>
}
</article>
}
</div>
}

View File

@ -0,0 +1,95 @@
@model WritingContextDisplayViewModel
@{
var scene = Model.Context!.Scene!;
var floorPlan = Model.FloorPlan;
var floorId = scene.InitialFloorPlanFloorID
?? floorPlan?.OccupancyScenes.FirstOrDefault(x => x.SceneID == scene.SceneID)?.InitialFloorPlanFloorID
?? floorPlan?.Floors.OrderBy(x => x.SortOrder).FirstOrDefault()?.FloorPlanFloorID;
var floor = floorPlan?.Floors.FirstOrDefault(x => x.FloorPlanFloorID == floorId);
var blocks = floor is null ? [] : floorPlan!.Blocks.Where(x => x.FloorPlanFloorID == floor.FloorPlanFloorID).ToList();
var tokens = floor is null ? [] : floorPlan!.OccupancyTokens.Where(x => x.SceneID == scene.SceneID && x.FloorPlanFloorID == floor.FloorPlanFloorID).ToList();
var tokensByLocation = tokens.GroupBy(x => x.LocationID).ToDictionary(x => x.Key, x => x.ToList());
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid writing-context-grid--place">
<article class="writing-context-card">
<p class="eyebrow">Location</p>
<h3>@TextOr(scene.LocationOptions.FirstOrDefault(x => x.Value == scene.PrimaryLocationID?.ToString())?.Text, "No primary location")</h3>
<dl class="writing-context-facts">
<dt>Floor plan</dt><dd>@(floorPlan?.FloorPlan.Name ?? "No floor plan linked to this scene")</dd>
<dt>Floor</dt><dd>@(floor?.Name ?? "No floor selected")</dd>
</dl>
</article>
<article class="writing-context-card writing-context-card--wide">
<p class="eyebrow">Occupancy</p>
@if (floorPlan is null)
{
<p>No floor plan linked to this scene.</p>
}
else if (floor is null || !blocks.Any())
{
<p>No placed rooms are available for this floor.</p>
}
else
{
<div class="writing-context-floor"
style="--floor-cols:@floor.GridWidth;--floor-rows:@floor.GridHeight;"
aria-label="@floor.Name floor plan">
@foreach (var block in blocks)
{
var locationTokens = block.LocationID.HasValue && tokensByLocation.TryGetValue(block.LocationID.Value, out var blockTokens)
? blockTokens
: [];
<div class="writing-context-floor-block"
style="grid-column:@(block.X + 1) / span @block.WidthCells;grid-row:@(block.Y + 1) / span @block.HeightCells;">
<strong>@block.DisplayLabel</strong>
@if (locationTokens.Any())
{
<span>@string.Join(", ", locationTokens.Select(x => x.DisplayName).Take(3))@(locationTokens.Count > 3 ? $" +{locationTokens.Count - 3}" : string.Empty)</span>
}
</div>
}
</div>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Characters</p>
@if (!scene.SceneCharacters.Any(x => !string.IsNullOrWhiteSpace(x.LocationName) || !string.IsNullOrWhiteSpace(x.LocationPath)))
{
<p>No character locations recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var character in scene.SceneCharacters.Where(x => !string.IsNullOrWhiteSpace(x.LocationName) || !string.IsNullOrWhiteSpace(x.LocationPath)))
{
<li><strong>@character.CharacterName</strong> @TextOr(character.LocationPath ?? character.LocationName, "Unknown")</li>
}
</ul>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Assets</p>
@if (!scene.SceneAssetLocations.Any() && !scene.AssetCustodyEvents.Any())
{
<p>No asset locations or custody changes recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var asset in scene.SceneAssetLocations.Take(6))
{
<li><strong>@asset.AssetName</strong> @asset.LocationPath</li>
}
@foreach (var custody in scene.AssetCustodyEvents.Take(6))
{
<li><strong>@custody.AssetName</strong> @TextOr(custody.CustodianSummary, custody.CustodyEventTypeName)</li>
}
</ul>
}
</article>
</div>

View File

@ -0,0 +1,58 @@
@model WritingContextDisplayViewModel
@{
var scene = Model.Context!.Scene!;
var sceneCharacterIds = scene.SceneCharacters.Select(x => x.CharacterID).ToHashSet();
var relationshipEvents = scene.RelationshipEvents;
var relationships = (Model.RelationshipMap?.Relationships ?? [])
.Where(x => !sceneCharacterIds.Any() || sceneCharacterIds.Contains(x.CharacterAID) || sceneCharacterIds.Contains(x.CharacterBID))
.ToList();
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid">
<article class="writing-context-card">
<p class="eyebrow">Changes In This Scene</p>
@if (!relationshipEvents.Any())
{
<p>No relationship changes recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var item in relationshipEvents)
{
<li>
<strong>@item.CharacterAName - @item.CharacterBName</strong>
<span>@item.RelationshipStateName@(item.Intensity.HasValue ? $" / intensity {item.Intensity}" : string.Empty)</span>
<p>@TextOr(item.Description, item.RelationshipTypeName)</p>
</li>
}
</ul>
}
</article>
<article class="writing-context-card writing-context-card--wide">
<p class="eyebrow">State At This Point</p>
@if (!relationships.Any())
{
<p>No visible relationships at this point.</p>
}
else
{
<div class="writing-context-relationship-list">
@foreach (var relationship in relationships.Take(12))
{
<div>
<strong>@relationship.CharacterAName - @relationship.CharacterBName</strong>
<span>@relationship.CurrentStateName</span>
<span class="status-pill @relationship.CssClass">@relationship.RelationshipCategoryName</span>
@if (relationship.CurrentIntensity.HasValue)
{
<small>Intensity @relationship.CurrentIntensity</small>
}
</div>
}
</div>
}
</article>
</div>

View File

@ -0,0 +1,96 @@
@model WritingContextDisplayViewModel
@{
var scene = Model.Context!.Scene!;
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid">
<article class="writing-context-card">
<p class="eyebrow">Threads</p>
@if (!scene.ThreadEvents.Any())
{
<p>No story thread events recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var item in scene.ThreadEvents)
{
<li><strong>@item.PlotLineName / @item.ThreadTitle</strong> @TextOr(item.EventTitle, item.EventTypeName)</li>
}
</ul>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Knowledge</p>
@if (!scene.CharacterKnowledge.Any())
{
<p>No knowledge changes recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var item in scene.CharacterKnowledge)
{
<li><strong>@item.CharacterName</strong> @item.KnowledgeStateName: @TextOr(item.Description, item.AssetName ?? item.ThreadTitle ?? "Knowledge change recorded.")</li>
}
</ul>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Dependencies</p>
@if (!scene.DependenciesThisSceneNeeds.Any() && !scene.DependenciesNeedingThisScene.Any())
{
<p>No dependencies recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var item in scene.DependenciesThisSceneNeeds.Concat(scene.DependenciesNeedingThisScene).Take(8))
{
<li><strong>@item.SceneDependencyTypeName</strong> @TextOr(item.Description, "Scene dependency recorded.")</li>
}
</ul>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Assets</p>
@if (!scene.AssetEvents.Any() && !scene.AssetCustodyEvents.Any())
{
<p>No important asset events recorded.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var item in scene.AssetEvents.Take(6))
{
<li><strong>@item.AssetName</strong> @TextOr(item.EventTitle, item.AssetEventTypeName)</li>
}
@foreach (var item in scene.AssetCustodyEvents.Take(6))
{
<li><strong>@item.AssetName</strong> @TextOr(item.CustodianSummary, item.CustodyEventTypeName)</li>
}
</ul>
}
</article>
<article class="writing-context-card writing-context-card--wide">
<p class="eyebrow">Continuity</p>
@if (!scene.Warnings.Any() && !scene.AgeContinuityWarnings.Any())
{
<p>No continuity warnings.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var warning in scene.Warnings.Concat(scene.AgeContinuityWarnings))
{
<li><strong>@warning.SeverityName</strong> @warning.Message</li>
}
</ul>
}
</article>
</div>

View File

@ -0,0 +1,53 @@
@model WritingIntentDisplayViewModel
@{
var scene = Model.Context?.Scene;
var workflow = scene?.Workflow;
var checklist = scene?.ChecklistItems.OrderBy(x => x.IsCompleted).ThenBy(x => x.SortOrder).ThenBy(x => x.ItemText).ToList() ?? [];
var completed = checklist.Count(x => x.IsCompleted);
}
<div class="writing-context-grid">
<article class="writing-context-card">
<p class="eyebrow">Workflow</p>
@if (workflow is null || scene is null)
{
<p>No current scene workflow.</p>
}
else
{
<dl class="writing-context-facts">
<dt>Status</dt><dd>@workflow.DraftStatus</dd>
<dt>Priority</dt><dd>@(workflow.Priority?.ToString() ?? "None")</dd>
<dt>Estimate</dt><dd>@(workflow.EstimatedWordCount?.ToString("N0") ?? "Not set")</dd>
<dt>Actual</dt><dd>@(workflow.ActualWordCount?.ToString("N0") ?? "Not set")</dd>
<dt>Blocked</dt><dd>@(workflow.IsBlocked ? (workflow.BlockedReason ?? "Blocked") : "No")</dd>
</dl>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Checklist</p>
@if (!checklist.Any())
{
<p>No checklist items.</p>
}
else
{
<p>@completed / @checklist.Count completed</p>
<ul class="writing-context-list writing-intent-checklist">
@foreach (var item in checklist)
{
<li class="@(item.IsCompleted ? "is-complete" : "")">
<span>@(item.IsCompleted ? "Done" : "Planned")</span>
<span>@item.ItemText</span>
</li>
}
</ul>
}
</article>
<article class="writing-context-card writing-context-card--wide">
<p class="eyebrow">Future Fulfilment</p>
<p>Stored checklist and workflow states are shown as recorded. Automatic fulfilment detection is not enabled here.</p>
</article>
</div>

View File

@ -0,0 +1,111 @@
@model WritingIntentDisplayViewModel
@{
var scene = Model.Context?.Scene;
var position = Model.Context?.Position;
var workflow = Model.ChapterWorkflow;
var chapter = workflow?.Chapter ?? Model.Context?.Chapter;
var book = workflow?.Book ?? Model.Context?.Book;
var project = workflow?.Project ?? Model.Context?.Project;
var projectId = position?.ProjectId ?? project?.ProjectID;
var bookId = position?.BookId ?? book?.BookID;
var chapterId = position?.ChapterId ?? chapter?.ChapterID;
var sceneId = position?.SceneId;
}
<div class="writing-context-content"
data-writing-intent-fragment
data-project-id="@projectId"
data-book-id="@bookId"
data-chapter-id="@chapterId"
data-scene-id="@sceneId"
data-follow-status="@Model.FollowStatus">
@if (!Model.HasChapter && scene is null)
{
<section class="writing-context-empty">
<p class="eyebrow">Waiting</p>
<h2>Waiting for Word...</h2>
<p>Open a linked manuscript or choose a planned chapter or scene once intent is available.</p>
</section>
}
else
{
<header class="writing-context-scene-header">
<div>
<p class="eyebrow">@project?.ProjectName</p>
<h2>@(scene?.SceneTitle ?? $"Chapter {chapter?.ChapterNumber:g}: {chapter?.ChapterTitle}")</h2>
<p>
@book?.BookDisplayTitle
@if (chapter is not null)
{
<span aria-hidden="true">/</span>
<span>Chapter @chapter.ChapterNumber:g</span>
}
@if (scene is not null)
{
<span aria-hidden="true">/</span>
<span>Scene @scene.SceneNumber:g</span>
}
</p>
@if (Model.IsChapterOnly)
{
<p>No current scene. Showing chapter intent.</p>
}
else if (scene is not null && string.IsNullOrWhiteSpace(scene.Summary) && string.IsNullOrWhiteSpace(scene.ScenePurposeNotes) && !scene.ChecklistItems.Any())
{
<p>No scene-specific plan. Showing chapter intent.</p>
}
</div>
<div class="writing-context-scene-actions">
@if (chapter is not null)
{
<label for="writing-intent-chapter-select">Jump to chapter</label>
<select id="writing-intent-chapter-select" class="form-select form-select-sm" data-writing-intent-chapter-select>
<option value="@chapter.ChapterID" selected>Chapter @chapter.ChapterNumber:g: @chapter.ChapterTitle</option>
</select>
}
<label for="writing-intent-scene-select">Jump to scene</label>
<select id="writing-intent-scene-select" class="form-select form-select-sm" data-writing-intent-scene-select>
<option value="">No current scene</option>
@foreach (var option in Model.SceneOptions)
{
<option value="@option.Value" selected="@option.Selected">@option.Text</option>
}
</select>
@if (scene is not null)
{
<a class="btn btn-outline-secondary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Edit Scene</a>
}
else if (chapter is not null)
{
<a class="btn btn-outline-secondary btn-sm" asp-controller="Writer" asp-action="Chapter" asp-route-id="@chapter.ChapterID">Edit Chapter Plan</a>
}
</div>
</header>
<nav class="writing-context-tabs" aria-label="Writing intent tabs">
<button type="button" class="active" data-writing-intent-tab="plan">Plan</button>
<button type="button" data-writing-intent-tab="must-include">Must Include</button>
<button type="button" data-writing-intent-tab="notes">Notes</button>
<button type="button" data-writing-intent-tab="threads">Threads</button>
<button type="button" data-writing-intent-tab="checklist">Checklist</button>
</nav>
<div class="writing-context-tab-panels">
<section class="writing-context-tab-panel active" data-writing-intent-panel="plan">
<partial name="_WritingIntentPlan" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-intent-panel="must-include" hidden>
<partial name="_WritingIntentMustInclude" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-intent-panel="notes" hidden>
<partial name="_WritingIntentNotes" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-intent-panel="threads" hidden>
<partial name="_WritingIntentThreads" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-intent-panel="checklist" hidden>
<partial name="_WritingIntentChecklist" model="Model" />
</section>
</div>
}
</div>

View File

@ -0,0 +1,42 @@
@model WritingIntentDisplayViewModel
@{
var scene = Model.Context?.Scene;
var goal = Model.ChapterWorkflow?.Goal;
var plannedItems = scene?.ChecklistItems.OrderBy(x => x.IsCompleted).ThenBy(x => x.SortOrder).ThenBy(x => x.ItemText).ToList() ?? [];
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid">
<article class="writing-context-card writing-context-card--wide">
<p class="eyebrow">Chapter Must Include</p>
<p>@TextOr(goal?.MustInclude, "No chapter-level must-include notes yet.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Scene Checklist</p>
@if (!plannedItems.Any())
{
<p>No scene checklist items.</p>
}
else
{
<ul class="writing-context-list writing-intent-checklist">
@foreach (var item in plannedItems)
{
<li class="@(item.IsCompleted ? "is-complete" : "")">
<span>@(item.IsCompleted ? "Done" : "Planned")</span>
<span>@item.ItemText</span>
</li>
}
</ul>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Relevant Goals</p>
<ul class="writing-context-list">
<li><strong>Goal</strong> @TextOr(goal?.GoalSummary, "No chapter goal summary yet.")</li>
<li><strong>Reader</strong> @TextOr(goal?.ReaderTakeaway, "No reader takeaway yet.")</li>
</ul>
</article>
</div>

View File

@ -0,0 +1,36 @@
@model WritingIntentDisplayViewModel
@{
var scene = Model.Context?.Scene;
var goal = Model.ChapterWorkflow?.Goal;
var notes = scene?.Notes.OrderByDescending(x => x.IsPinned).ThenBy(x => x.IsResolved).ThenByDescending(x => x.UpdatedDate).ToList() ?? [];
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid">
<article class="writing-context-card">
<p class="eyebrow">Chapter Risks</p>
<p>@TextOr(goal?.RisksOrConcerns, "No chapter risks or concerns recorded.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Scene Notes</p>
@if (!notes.Any())
{
<p>No writing notes for this scene.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var note in notes)
{
<li>
<strong>@(string.IsNullOrWhiteSpace(note.NoteTitle) ? note.TypeName : note.NoteTitle)</strong>
@if (note.IsPinned) { <span class="writing-intent-status-label">Pinned</span> }
@if (note.IsResolved) { <span class="writing-intent-status-label">Resolved</span> }
<p>@note.NoteText</p>
</li>
}
</ul>
}
</article>
</div>

View File

@ -0,0 +1,53 @@
@model WritingIntentDisplayViewModel
@{
var scene = Model.Context?.Scene;
var workflow = Model.ChapterWorkflow;
var chapter = workflow?.Chapter ?? Model.Context?.Chapter;
var goal = workflow?.Goal;
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid writing-context-grid--overview">
<article class="writing-context-card writing-context-card--hero">
<p class="eyebrow">Chapter Plan</p>
<h3>Chapter @chapter?.ChapterNumber:g: @chapter?.ChapterTitle</h3>
<p>@TextOr(chapter?.Summary, "No chapter summary has been written yet.")</p>
<dl class="writing-context-facts">
<dt>Purpose</dt><dd>@TextOr(chapter?.ChapterPurposeName, "No chapter purpose selected.")</dd>
<dt>Goal</dt><dd>@TextOr(goal?.GoalSummary, "No chapter goal summary yet.")</dd>
</dl>
</article>
<article class="writing-context-card">
<p class="eyebrow">Current Scene</p>
@if (scene is null)
{
<p>No current scene. Chapter intent is shown until Word reports a scene.</p>
}
else
{
<h3>Scene @scene.SceneNumber:g: @scene.SceneTitle</h3>
<p>@TextOr(scene.Summary, "No scene summary has been written yet.")</p>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Scene Purpose</p>
<p>@TextOr(scene?.ScenePurposeNotes, "No scene-specific purpose notes yet.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Expected Outcome</p>
<p>@TextOr(scene?.SceneOutcomeNotes, "No expected outcome has been captured for this scene.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Emotional Goal</p>
<p>@TextOr(goal?.EmotionalGoal, "No emotional goal recorded for this chapter.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Reader Takeaway</p>
<p>@TextOr(goal?.ReaderTakeaway, "No reader takeaway recorded for this chapter.")</p>
</article>
</div>

View File

@ -0,0 +1,29 @@
@model WritingIntentDisplayViewModel
@{
var scene = Model.Context?.Scene;
var threadEvents = scene?.ThreadEvents.ToList() ?? [];
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid">
<article class="writing-context-card writing-context-card--wide">
<p class="eyebrow">Story Threads</p>
@if (!threadEvents.Any())
{
<p>No story thread events recorded for this scene.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var item in threadEvents)
{
<li>
<strong>@item.PlotLineName / @item.ThreadTitle</strong>
<span class="writing-intent-status-label">@TextOr(item.EventTypeName, "Thread event")</span>
<p>@TextOr(item.EventTitle, item.EventDescription ?? "Thread intention recorded here.")</p>
</li>
}
</ul>
}
</article>
</div>

View File

@ -3,6 +3,322 @@ html {
min-height: 100%; min-height: 100%;
} }
.writing-context-page {
min-height: calc(100vh - 8rem);
color: var(--plotline-ink);
}
.writing-context-shell-header,
.writing-context-scene-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
margin-bottom: 1rem;
}
.writing-context-shell-header h1,
.writing-context-scene-header h2 {
margin: 0;
}
.writing-context-status {
display: inline-flex;
align-items: center;
gap: .5rem;
padding: .4rem .65rem;
border: 1px solid var(--plotline-line);
border-radius: 999px;
background: var(--plotline-panel);
color: var(--plotline-muted);
font-size: .9rem;
white-space: nowrap;
}
.writing-context-status-dot {
width: .55rem;
height: .55rem;
border-radius: 50%;
background: #2f8f63;
box-shadow: 0 0 0 .18rem rgba(47, 143, 99, .15);
}
.writing-context-empty {
min-height: 42vh;
display: grid;
align-content: center;
justify-items: start;
padding: 3rem;
border: 1px solid var(--plotline-line);
border-radius: .5rem;
background: var(--plotline-panel);
}
.writing-context-scene-header {
padding: 1rem 0 1.2rem;
border-bottom: 1px solid var(--plotline-line);
}
.writing-context-scene-header p {
margin: .35rem 0 0;
color: var(--plotline-muted);
}
.writing-context-scene-actions {
display: grid;
grid-template-columns: minmax(18rem, 28rem) auto;
gap: .5rem;
align-items: end;
}
.writing-context-scene-actions label {
grid-column: 1 / -1;
margin: 0;
color: var(--plotline-muted);
font-size: .82rem;
font-weight: 700;
}
.writing-context-tabs {
display: flex;
flex-wrap: wrap;
gap: .35rem;
margin: 1rem 0;
}
.writing-context-tabs button {
border: 1px solid var(--plotline-line);
border-radius: .4rem;
background: var(--plotline-panel);
color: var(--plotline-ink);
padding: .55rem .85rem;
font-weight: 700;
}
.writing-context-tabs button.active {
background: var(--plotline-accent);
border-color: var(--plotline-accent);
color: #fff;
}
.writing-context-tab-panel[hidden] {
display: none;
}
.writing-context-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.writing-context-grid--overview {
grid-template-columns: minmax(0, 1.35fr) minmax(20rem, .65fr);
}
.writing-context-grid--place {
grid-template-columns: minmax(18rem, .7fr) minmax(0, 1.3fr);
}
.writing-context-card {
min-width: 0;
padding: 1rem;
border: 1px solid var(--plotline-line);
border-radius: .5rem;
background: var(--plotline-panel);
}
.writing-context-card--hero,
.writing-context-card--wide {
grid-column: span 2;
}
.writing-context-card h3 {
margin: 0 0 .65rem;
font-size: 1.2rem;
}
.writing-context-facts {
display: grid;
grid-template-columns: minmax(7rem, .45fr) minmax(0, 1fr);
gap: .45rem .75rem;
margin: 0;
}
.writing-context-facts dt {
color: var(--plotline-muted);
font-weight: 700;
}
.writing-context-facts dd {
margin: 0;
}
.writing-context-chip-row {
display: flex;
flex-wrap: wrap;
gap: .4rem;
margin-bottom: .65rem;
}
.writing-context-chip-row span,
.writing-context-relationship-list span {
display: inline-flex;
align-items: center;
border: 1px solid var(--plotline-line);
border-radius: 999px;
padding: .2rem .55rem;
background: var(--plotline-paper);
color: var(--plotline-muted);
font-size: .82rem;
}
.writing-context-list {
display: grid;
gap: .7rem;
padding: 0;
margin: 0;
list-style: none;
}
.writing-context-list li {
min-width: 0;
}
.writing-context-list p {
margin: .2rem 0 0;
color: var(--plotline-muted);
}
.writing-intent-status-label {
display: inline-flex;
align-items: center;
margin-left: .35rem;
padding: .1rem .45rem;
border: 1px solid var(--plotline-line);
border-radius: 999px;
background: var(--plotline-paper);
color: var(--plotline-muted);
font-size: .78rem;
font-weight: 700;
}
.writing-intent-checklist li {
display: grid;
grid-template-columns: 4.5rem minmax(0, 1fr);
gap: .5rem;
align-items: start;
}
.writing-intent-checklist li > span:first-child {
color: var(--plotline-muted);
font-size: .78rem;
font-weight: 800;
text-transform: uppercase;
}
.writing-intent-checklist li.is-complete > span:last-child {
color: var(--plotline-muted);
text-decoration: line-through;
}
.writing-context-people-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 1rem;
}
.writing-context-person-heading {
display: flex;
align-items: center;
gap: .75rem;
margin-bottom: .9rem;
}
.writing-context-person-heading h3,
.writing-context-person-heading p {
margin: 0;
}
.writing-context-person-heading p,
.writing-context-mini-section {
color: var(--plotline-muted);
}
.writing-context-mini-section {
margin-top: .8rem;
padding-top: .8rem;
border-top: 1px solid var(--plotline-line);
}
.writing-context-mini-section p {
margin: 0 0 .35rem;
}
.writing-context-relationship-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: .7rem;
}
.writing-context-relationship-list > div {
display: grid;
gap: .35rem;
padding: .75rem;
border: 1px solid var(--plotline-line);
border-radius: .4rem;
background: var(--plotline-paper);
}
.writing-context-floor {
display: grid;
grid-template-columns: repeat(var(--floor-cols), minmax(0, 1fr));
grid-template-rows: repeat(var(--floor-rows), minmax(1.2rem, 1fr));
gap: .18rem;
min-height: 34rem;
padding: .5rem;
border: 1px solid var(--plotline-line);
border-radius: .45rem;
background: color-mix(in srgb, var(--plotline-paper) 88%, var(--plotline-line));
overflow: auto;
}
.writing-context-floor-block {
display: grid;
align-content: start;
gap: .2rem;
min-width: 0;
min-height: 2.2rem;
padding: .35rem;
border: 1px solid color-mix(in srgb, var(--plotline-accent) 35%, var(--plotline-line));
border-radius: .3rem;
background: color-mix(in srgb, var(--plotline-panel) 92%, var(--plotline-accent));
font-size: .82rem;
}
.writing-context-floor-block span {
color: var(--plotline-muted);
overflow-wrap: anywhere;
}
@media (max-width: 900px) {
.writing-context-shell-header,
.writing-context-scene-header {
display: grid;
}
.writing-context-scene-actions,
.writing-context-grid,
.writing-context-grid--overview,
.writing-context-grid--place {
grid-template-columns: 1fr;
}
.writing-context-card--hero,
.writing-context-card--wide {
grid-column: auto;
}
}
@media (min-width: 768px) { @media (min-width: 768px) {
html { html {
font-size: 16px; font-size: 16px;
@ -3884,6 +4200,57 @@ body.dragging-location [data-drag-type="location"] {
box-shadow: 0 14px 34px rgba(36, 32, 26, 0.05); box-shadow: 0 14px 34px rgba(36, 32, 26, 0.05);
} }
.writing-workspace-launcher {
border-color: rgba(47, 111, 99, 0.22);
background: linear-gradient(180deg, #fff 0%, rgba(238, 245, 242, 0.68) 100%);
}
.writing-workspace-status-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.writing-workspace-status-grid div {
display: grid;
gap: 2px;
border: 1px solid var(--plotline-line);
border-radius: 8px;
padding: 10px 12px;
background: var(--plotline-panel);
}
.writing-workspace-status-grid span {
color: var(--plotline-muted);
font-size: 0.76rem;
font-weight: 800;
text-transform: uppercase;
}
.writing-workspace-status-grid strong {
color: var(--plotline-ink);
font-size: 1rem;
}
.writing-workspace-message {
min-height: 1.35rem;
}
.writing-workspace-role-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.65rem;
border: 1px solid rgba(47, 111, 99, 0.22);
border-radius: 999px;
background: var(--plotline-soft);
color: var(--plotline-accent-dark);
font-size: 0.9rem;
font-weight: 700;
white-space: nowrap;
}
.writer-queue-card { .writer-queue-card {
border: 1px solid var(--plotline-line); border: 1px solid var(--plotline-line);
border-radius: 12px; border-radius: 12px;
@ -4343,6 +4710,10 @@ body.dragging-location [data-drag-type="location"] {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.writing-workspace-status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.writer-checklist-row, .writer-checklist-row,
.writer-add-inline { .writer-add-inline {
grid-template-columns: 1fr; grid-template-columns: 1fr;

File diff suppressed because one or more lines are too long

View File

@ -601,6 +601,71 @@ body {
background: #fef2f2; background: #fef2f2;
} }
.word-companion-marker-status {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.word-companion-marker-status div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid rgba(111, 75, 45, 0.16);
border-radius: 6px;
padding: 6px 8px;
background: #fffdf8;
}
.word-companion-marker-status span {
color: var(--companion-muted);
font-size: 0.76rem;
font-weight: 800;
text-transform: uppercase;
}
.word-companion-dialog-marker-status {
margin: 2px 0;
}
.word-companion-maintenance-group {
display: grid;
gap: 8px;
border: 1px solid rgba(111, 75, 45, 0.16);
border-radius: 8px;
padding: 9px;
background: #fffdf8;
}
.word-companion-maintenance-group-strong {
border-color: #fecaca;
background: #fff8f6;
}
.word-companion-maintenance-group p {
margin: 3px 0 0;
color: var(--companion-muted);
font-size: 0.84rem;
}
.word-companion-maintenance-group button {
width: 100%;
min-height: 31px;
border: 1px solid var(--companion-line);
border-radius: 6px;
padding: 6px 8px;
background: #fffdf8;
color: var(--companion-accent-dark);
font: inherit;
font-size: 0.8rem;
font-weight: 800;
}
.word-companion-writing-workspace .word-companion-actions {
grid-template-columns: 1fr;
}
.word-companion-message:empty { .word-companion-message:empty {
display: none; display: none;
} }

File diff suppressed because one or more lines are too long

View File

@ -63,6 +63,52 @@
}); });
})(); })();
(() => {
const storyPositionChangedEventName = "plotdirector:story-position-changed";
const normaliseStoryPositionPayload = (payload, source) => {
const position = payload?.position || payload?.Position || payload || {};
const readNumber = (...values) => {
for (const value of values) {
const parsed = Number.parseInt(value || "", 10);
if (Number.isInteger(parsed) && parsed > 0) {
return parsed;
}
}
return null;
};
return {
source: source || payload?.source || payload?.Source || "Navigation",
projectId: readNumber(position.projectId, position.ProjectId, payload?.projectId, payload?.ProjectId),
bookId: readNumber(position.bookId, position.BookId, payload?.bookId, payload?.BookId),
chapterId: readNumber(position.chapterId, position.ChapterId, payload?.chapterId, payload?.ChapterId),
sceneId: readNumber(position.sceneId, position.SceneId, payload?.sceneId, payload?.SceneId),
canonicalUrl: payload?.canonicalUrl || payload?.CanonicalUrl || payload?.sceneUrl || payload?.SceneUrl || ""
};
};
const publishStoryPositionChanged = (payload, source) => {
const detail = normaliseStoryPositionPayload(payload, source);
if (!detail.projectId) {
return detail;
}
document.dispatchEvent(new CustomEvent(storyPositionChangedEventName, {
bubbles: true,
detail
}));
return detail;
};
window.PlotDirectorStoryPosition = {
eventName: storyPositionChangedEventName,
normalise: normaliseStoryPositionPayload,
publishChanged: publishStoryPositionChanged
};
})();
(() => { (() => {
const toggle = document.querySelector("[data-word-companion-follow-toggle]"); const toggle = document.querySelector("[data-word-companion-follow-toggle]");
if (!toggle || !window.signalR) { if (!toggle || !window.signalR) {
@ -118,6 +164,14 @@
return false; return false;
} }
if (document.querySelector("[data-writing-mode]")
|| path === "/writing/context"
|| path.startsWith("/writing/context/")
|| path === "/writing/intent"
|| path.startsWith("/writing/intent/")) {
return false;
}
return !unsafePathPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)); return !unsafePathPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}; };
@ -160,7 +214,9 @@
.build(); .build();
connection.on("wordCompanionSceneChanged", (payload) => { connection.on("wordCompanionSceneChanged", (payload) => {
const sceneId = Number.parseInt(payload?.sceneId || payload?.SceneId || "", 10); const position = window.PlotDirectorStoryPosition?.publishChanged(payload, "WordCompanion")
|| { sceneId: Number.parseInt(payload?.sceneId || payload?.SceneId || "", 10) };
const sceneId = position.sceneId;
if (!Number.isInteger(sceneId) || sceneId <= 0 || sceneId === lastSceneId) { if (!Number.isInteger(sceneId) || sceneId <= 0 || sceneId === lastSceneId) {
return; return;
} }
@ -170,6 +226,10 @@
navigateIfAllowed(sceneUrl); navigateIfAllowed(sceneUrl);
}); });
connection.on("storyPositionChanged", (payload) => {
window.PlotDirectorStoryPosition?.publishChanged(payload, payload?.source || payload?.Source || "WordCompanion");
});
connection.on("wordCompanionCharactersCreated", (payload) => { connection.on("wordCompanionCharactersCreated", (payload) => {
const projectId = Number.parseInt(payload?.projectId || payload?.ProjectId || "", 10); const projectId = Number.parseInt(payload?.projectId || payload?.ProjectId || "", 10);
if (!Number.isInteger(projectId) || projectId <= 0 || projectId === lastCharactersProjectId) { if (!Number.isInteger(projectId) || projectId <= 0 || projectId === lastCharactersProjectId) {

File diff suppressed because one or more lines are too long

View File

@ -44,6 +44,9 @@
const unlinkWordDocumentDialog = document.querySelector("[data-unlink-word-document-dialog]"); const unlinkWordDocumentDialog = document.querySelector("[data-unlink-word-document-dialog]");
const unlinkWordDocumentConfirm = document.querySelector("[data-unlink-word-document-confirm]"); const unlinkWordDocumentConfirm = document.querySelector("[data-unlink-word-document-confirm]");
const unlinkWordDocumentCancel = document.querySelector("[data-unlink-word-document-cancel]"); const unlinkWordDocumentCancel = document.querySelector("[data-unlink-word-document-cancel]");
const cleanUnlinkWordDocumentDialog = document.querySelector("[data-clean-unlink-word-document-dialog]");
const cleanUnlinkWordDocumentConfirm = document.querySelector("[data-clean-unlink-word-document-confirm]");
const cleanUnlinkWordDocumentCancel = document.querySelector("[data-clean-unlink-word-document-cancel]");
const officeStatus = document.querySelector("[data-office-status]"); const officeStatus = document.querySelector("[data-office-status]");
const connectionStatus = document.querySelector("[data-connection-status]"); const connectionStatus = document.querySelector("[data-connection-status]");
const summaryConnection = document.querySelector("[data-summary-connection]"); const summaryConnection = document.querySelector("[data-summary-connection]");
@ -75,6 +78,17 @@
const anchorSceneId = document.querySelector("[data-anchor-scene-id]"); const anchorSceneId = document.querySelector("[data-anchor-scene-id]");
const attachPlotDirectorIdsButton = document.querySelector("[data-attach-plotdirector-ids]"); const attachPlotDirectorIdsButton = document.querySelector("[data-attach-plotdirector-ids]");
const unlinkWordDocumentButton = document.querySelector("[data-unlink-word-document]"); const unlinkWordDocumentButton = document.querySelector("[data-unlink-word-document]");
const cleanUnlinkWordDocumentButton = document.querySelector("[data-clean-unlink-word-document]");
const markerBindingStatus = document.querySelector("[data-marker-binding-status]");
const markerChapterCount = document.querySelector("[data-marker-chapter-count]");
const markerSceneCount = document.querySelector("[data-marker-scene-count]");
const unlinkDialogChapterCount = document.querySelector("[data-unlink-dialog-chapter-count]");
const unlinkDialogSceneCount = document.querySelector("[data-unlink-dialog-scene-count]");
const cleanDialogChapterCount = document.querySelector("[data-clean-dialog-chapter-count]");
const cleanDialogSceneCount = document.querySelector("[data-clean-dialog-scene-count]");
const openWritingWorkspaceButton = document.querySelector("[data-open-writing-workspace]");
const openWritingContextButton = document.querySelector("[data-open-writing-context]");
const openWritingIntentButton = document.querySelector("[data-open-writing-intent]");
const plotDirectorSceneDetails = document.querySelector("[data-plotdirector-scene-details]"); const plotDirectorSceneDetails = document.querySelector("[data-plotdirector-scene-details]");
const plotDirectorSceneTitle = document.querySelector("[data-plotdirector-scene-title]"); const plotDirectorSceneTitle = document.querySelector("[data-plotdirector-scene-title]");
const plotDirectorRevisionStatus = document.querySelector("[data-plotdirector-revision-status]"); const plotDirectorRevisionStatus = document.querySelector("[data-plotdirector-revision-status]");
@ -253,6 +267,34 @@
locations: [], locations: [],
primaryLocationId: null primaryLocationId: null
}; };
let latestMarkerState = {
chapterCount: 0,
sceneCount: 0,
totalCount: 0,
available: false,
message: "Not inspected"
};
const plotDirectorMarkerPrefixes = {
chapter: "PD-CHAPTER-",
scene: "PD-SCENE-"
};
const classifyPlotDirectorContentControl = (control) => {
const tag = String(control?.tag || "").trim().toUpperCase();
if (tag.startsWith(plotDirectorMarkerPrefixes.chapter)) {
return "chapter";
}
if (tag.startsWith(plotDirectorMarkerPrefixes.scene)) {
return "scene";
}
return "";
};
window.PlotDirectorWordMarkers = {
classify: classifyPlotDirectorContentControl
};
const runtimeDebug = (message) => { const runtimeDebug = (message) => {
const now = Date.now(); const now = Date.now();
@ -458,6 +500,112 @@
setHidden(unlinkWordDocumentButton, !firstRunDocumentBinding); setHidden(unlinkWordDocumentButton, !firstRunDocumentBinding);
}; };
const updateMarkerStatusDisplay = (state = latestMarkerState) => {
const binding = currentDocumentBinding();
setText(markerBindingStatus, binding ? "Linked" : "Not linked");
setText(markerChapterCount, state.available ? String(state.chapterCount) : "-");
setText(markerSceneCount, state.available ? String(state.sceneCount) : "-");
setText(unlinkDialogChapterCount, state.available ? String(state.chapterCount) : "-");
setText(unlinkDialogSceneCount, state.available ? String(state.sceneCount) : "-");
setText(cleanDialogChapterCount, state.available ? String(state.chapterCount) : "-");
setText(cleanDialogSceneCount, state.available ? String(state.sceneCount) : "-");
if (cleanUnlinkWordDocumentButton) {
cleanUnlinkWordDocumentButton.disabled = !wordHostAvailable || !officeReady;
}
};
const inspectPlotDirectorMarkers = async () => {
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
latestMarkerState = {
chapterCount: 0,
sceneCount: 0,
totalCount: 0,
available: false,
message: "Word document access is unavailable."
};
updateMarkerStatusDisplay();
return latestMarkerState;
}
try {
latestMarkerState = await window.Word.run(async (context) => {
const controls = context.document.contentControls;
controls.load("items/tag,title");
await context.sync();
let chapterCount = 0;
let sceneCount = 0;
for (const control of controls.items || []) {
const markerType = classifyPlotDirectorContentControl(control);
if (markerType === "chapter") {
chapterCount += 1;
} else if (markerType === "scene") {
sceneCount += 1;
}
}
return {
chapterCount,
sceneCount,
totalCount: chapterCount + sceneCount,
available: true,
message: "Marker inspection complete."
};
});
updateMarkerStatusDisplay(latestMarkerState);
return latestMarkerState;
} catch (error) {
console.error("Unable to inspect PlotDirector markers.", error);
latestMarkerState = {
chapterCount: 0,
sceneCount: 0,
totalCount: 0,
available: false,
message: `Unable to inspect markers: ${errorText(error)}`
};
updateMarkerStatusDisplay();
throw error;
}
};
const removePlotDirectorContentControlsPreservingContent = async () => {
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
throw new Error("Word document access is unavailable.");
}
return await window.Word.run(async (context) => {
const controls = context.document.contentControls;
controls.load("items/tag,title");
await context.sync();
let chapterCount = 0;
let sceneCount = 0;
for (const control of controls.items || []) {
const markerType = classifyPlotDirectorContentControl(control);
if (!markerType) {
continue;
}
if (markerType === "chapter") {
chapterCount += 1;
} else if (markerType === "scene") {
sceneCount += 1;
}
control.delete(true);
}
await context.sync();
return {
chapterCount,
sceneCount,
totalCount: chapterCount + sceneCount,
available: true,
message: "PlotDirector markers removed."
};
});
};
const setSyncStatus = (message) => { const setSyncStatus = (message) => {
setText(syncStatus, message); setText(syncStatus, message);
}; };
@ -1165,6 +1313,7 @@
setText(linkedProjectTitle, runtimeProjectTitle()); setText(linkedProjectTitle, runtimeProjectTitle());
setText(linkedBookTitle, runtimeBookTitle()); setText(linkedBookTitle, runtimeBookTitle());
} }
updateMarkerStatusDisplay();
}; };
const selectedProject = () => { const selectedProject = () => {
@ -1203,6 +1352,45 @@
return Number.isInteger(bookId) && bookId > 0 ? bookId : null; return Number.isInteger(bookId) && bookId > 0 ? bookId : null;
}; };
const writingWorkspaceScope = () => ({
projectId: selectedPresenceProjectId(),
bookId: selectedPresenceBookId()
});
const writingWindowName = (role) => {
const scope = writingWorkspaceScope();
const suffix = scope.bookId ? `Book_${scope.bookId}` : scope.projectId ? `Project_${scope.projectId}` : "General";
return `PlotDirector${role}_${suffix}`;
};
const writingWorkspaceUrl = (target) => {
const scope = writingWorkspaceScope();
const url = new URL(target === "workspace" ? "/Writer" : `/Writing/${target}`, window.location.origin);
if (scope.projectId) {
url.searchParams.set("projectId", scope.projectId);
}
if (scope.bookId && target !== "workspace") {
url.searchParams.set("bookId", scope.bookId);
}
if (detectedChapterAnchorId && target !== "workspace") {
url.searchParams.set("chapterId", detectedChapterAnchorId);
}
if (detectedSceneAnchorId && target !== "workspace") {
url.searchParams.set("sceneId", detectedSceneAnchorId);
}
return `${url.pathname}${url.search}`;
};
const openWritingWindow = (target) => {
const role = target === "context" ? "Context" : target === "intent" ? "Intent" : "Workspace";
const opened = window.open(writingWorkspaceUrl(target), writingWindowName(role));
if (opened) {
opened.focus();
} else {
setDocumentMessage("Your browser blocked the writing workspace window. Use the browser pop-up prompt to allow PlotDirector windows.");
}
};
const currentDocumentName = () => { const currentDocumentName = () => {
const url = String(window.Office?.context?.document?.url || "").trim(); const url = String(window.Office?.context?.document?.url || "").trim();
if (!url) { if (!url) {
@ -1557,6 +1745,18 @@
}); });
}); });
const saveWordDocumentIfAvailable = () => new Promise((resolve) => {
const document = window.Office?.context?.document;
if (!document || typeof document.saveAsync !== "function") {
resolve(false);
return;
}
document.saveAsync((result) => {
resolve(result.status === window.Office.AsyncResultStatus.Succeeded);
});
});
const ensureDocumentGuid = () => { const ensureDocumentGuid = () => {
if (firstRunDocumentBinding?.documentGuid) { if (firstRunDocumentBinding?.documentGuid) {
return firstRunDocumentBinding.documentGuid; return firstRunDocumentBinding.documentGuid;
@ -2202,6 +2402,7 @@
await context.sync(); await context.sync();
}); });
await inspectPlotDirectorMarkers().catch(() => null);
}; };
const friendlyScanError = (error) => { const friendlyScanError = (error) => {
@ -2924,6 +3125,7 @@
refreshDocumentButton.disabled = false; refreshDocumentButton.disabled = false;
refreshDocumentButton.textContent = "Refresh Document Structure"; refreshDocumentButton.textContent = "Refresh Document Structure";
} }
await inspectPlotDirectorMarkers().catch(() => null);
} }
}; };
@ -3138,6 +3340,7 @@
await context.sync(); await context.sync();
}); });
await inspectPlotDirectorMarkers().catch(() => null);
}; };
const resultSummaryText = (summary) => const resultSummaryText = (summary) =>
@ -3409,7 +3612,7 @@
const requestUnlinkWordDocumentConfirmation = () => new Promise((resolve) => { const requestUnlinkWordDocumentConfirmation = () => new Promise((resolve) => {
if (!unlinkWordDocumentDialog || typeof unlinkWordDocumentDialog.showModal !== "function") { if (!unlinkWordDocumentDialog || typeof unlinkWordDocumentDialog.showModal !== "function") {
resolve(window.confirm("This will remove PlotDirector binding metadata from this Word document.\n\nIf PlotDirector is reachable, it will also unlink this Book in PlotDirector.\n\nYour manuscript text will not be changed.\n\nContinue?")); resolve(window.confirm(`This will remove PlotDirector binding metadata from this Word document and unlink this Book in PlotDirector.\n\nHidden PlotDirector anchors will be left in place for possible reuse.\n\nCurrent marker count:\n${latestMarkerState.chapterCount} chapter markers\n${latestMarkerState.sceneCount} scene markers\n\nYour manuscript text will not be changed.\n\nContinue?`));
return; return;
} }
@ -3433,6 +3636,33 @@
unlinkWordDocumentDialog.showModal(); unlinkWordDocumentDialog.showModal();
}); });
const requestCleanUnlinkWordDocumentConfirmation = () => new Promise((resolve) => {
const message = `This will remove PlotDirector hidden anchors from this Word document while preserving manuscript text.\n\nThis will remove:\n${latestMarkerState.chapterCount} PlotDirector chapter markers\n${latestMarkerState.sceneCount} PlotDirector scene markers\nthe document's PlotDirector binding\n\nThe document can be linked again later if desired.\n\nContinue?`;
if (!cleanUnlinkWordDocumentDialog || typeof cleanUnlinkWordDocumentDialog.showModal !== "function") {
resolve(window.confirm(message));
return;
}
const cleanup = (confirmed) => {
cleanUnlinkWordDocumentConfirm?.removeEventListener("click", confirmHandler);
cleanUnlinkWordDocumentCancel?.removeEventListener("click", cancelHandler);
cleanUnlinkWordDocumentDialog?.removeEventListener("cancel", cancelEventHandler);
cleanUnlinkWordDocumentDialog?.close();
resolve(confirmed);
};
const confirmHandler = () => cleanup(true);
const cancelHandler = () => cleanup(false);
const cancelEventHandler = (event) => {
event.preventDefault();
cleanup(false);
};
cleanUnlinkWordDocumentConfirm?.addEventListener("click", confirmHandler);
cleanUnlinkWordDocumentCancel?.addEventListener("click", cancelHandler);
cleanUnlinkWordDocumentDialog?.addEventListener("cancel", cancelEventHandler);
cleanUnlinkWordDocumentDialog.showModal();
});
const importFirstRunManuscript = async () => { const importFirstRunManuscript = async () => {
const project = selectedFirstRunProject(); const project = selectedFirstRunProject();
const book = selectedFirstRunBook(); const book = selectedFirstRunBook();
@ -3740,6 +3970,12 @@
return; return;
} }
try {
await inspectPlotDirectorMarkers();
} catch {
setDocumentMessage("Marker counts are unavailable. You can still unlink without removing hidden anchors.");
}
const confirmed = await requestUnlinkWordDocumentConfirmation(); const confirmed = await requestUnlinkWordDocumentConfirmation();
if (!confirmed) { if (!confirmed) {
setDocumentMessage("Unlink cancelled."); setDocumentMessage("Unlink cancelled.");
@ -3758,6 +3994,7 @@
bookId: binding.bookId bookId: binding.bookId
}); });
await removeDocumentBinding(); await removeDocumentBinding();
await inspectPlotDirectorMarkers().catch(() => null);
await returnToUnboundOnboarding("Manuscript unlinked."); await returnToUnboundOnboarding("Manuscript unlinked.");
} catch (error) { } catch (error) {
console.error("Unable to unlink manuscript binding.", error); console.error("Unable to unlink manuscript binding.", error);
@ -3766,6 +4003,7 @@
if (removeLocalOnly) { if (removeLocalOnly) {
try { try {
await removeDocumentBinding(); await removeDocumentBinding();
await inspectPlotDirectorMarkers().catch(() => null);
await returnToUnboundOnboarding("Word metadata removed. Unlink the Book manually in PlotDirector if it still shows as linked."); await returnToUnboundOnboarding("Word metadata removed. Unlink the Book manually in PlotDirector if it still shows as linked.");
} catch (metadataError) { } catch (metadataError) {
console.error("Unable to remove Word document metadata.", metadataError); console.error("Unable to remove Word document metadata.", metadataError);
@ -3777,12 +4015,100 @@
} finally { } finally {
if (unlinkWordDocumentButton) { if (unlinkWordDocumentButton) {
unlinkWordDocumentButton.disabled = false; unlinkWordDocumentButton.disabled = false;
unlinkWordDocumentButton.textContent = "Unlink this Word document"; unlinkWordDocumentButton.textContent = "Unlink from PlotDirector";
} }
updateUnlinkWordDocumentButton(); updateUnlinkWordDocumentButton();
} }
}; };
const cleanAndUnlinkWordDocument = async () => {
console.debug("Word Companion clean and unlink clicked");
const binding = currentDocumentBinding();
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
setDocumentMessage("Word document access is unavailable.");
return;
}
setDocumentMessage("Inspecting PlotDirector markers...");
try {
await inspectPlotDirectorMarkers();
} catch (error) {
setDocumentMessage(`Unable to inspect markers: ${errorText(error)}`);
return;
}
const confirmed = await requestCleanUnlinkWordDocumentConfirmation();
if (!confirmed) {
setDocumentMessage("Clean and unlink cancelled.");
return;
}
if (cleanUnlinkWordDocumentButton) {
cleanUnlinkWordDocumentButton.disabled = true;
cleanUnlinkWordDocumentButton.textContent = "Cleaning...";
}
if (unlinkWordDocumentButton) {
unlinkWordDocumentButton.disabled = true;
}
try {
setDocumentMessage("Removing PlotDirector hidden anchors...");
await removePlotDirectorContentControlsPreservingContent();
await saveWordDocumentIfAvailable();
try {
await inspectPlotDirectorMarkers();
} catch (verificationError) {
console.error("Unable to verify PlotDirector marker cleanup.", verificationError);
setDocumentMessage(`PlotDirector markers were removed, but cleanup verification failed: ${errorText(verificationError)}`);
return;
}
if (latestMarkerState.available && latestMarkerState.totalCount > 0) {
setDocumentMessage(`Cleanup stopped: ${latestMarkerState.totalCount} PlotDirector marker${latestMarkerState.totalCount === 1 ? "" : "s"} remain.`);
return;
}
setDocumentMessage("Removing PlotDirector binding metadata...");
try {
await removeDocumentBinding();
} catch (metadataError) {
console.error("Unable to remove Word document metadata after marker cleanup.", metadataError);
setDocumentMessage(`PlotDirector markers were removed, but Word binding metadata remains: ${errorText(metadataError)}`);
return;
}
let serverMessage = "";
if (binding?.documentGuid && Number.isInteger(binding.bookId) && binding.bookId > 0) {
try {
await postJson("/api/word-companion/manuscript/unlink", {
documentGuid: binding.documentGuid,
bookId: binding.bookId
});
} catch (serverError) {
console.error("Word document cleaned, but PlotDirector server unlink failed.", serverError);
serverMessage = " Word document is clean, but unlink the Book manually in PlotDirector if it still shows as linked.";
}
}
await inspectPlotDirectorMarkers().catch(() => null);
await returnToUnboundOnboarding(`Word document cleaned and unlinked.${serverMessage}`);
} catch (error) {
console.error("Unable to clean PlotDirector markers.", error);
setDocumentMessage(`Cleanup failed before unlinking: ${errorText(error)}`);
} finally {
if (cleanUnlinkWordDocumentButton) {
cleanUnlinkWordDocumentButton.disabled = false;
cleanUnlinkWordDocumentButton.textContent = "Clean and unlink";
}
if (unlinkWordDocumentButton) {
unlinkWordDocumentButton.disabled = false;
}
updateUnlinkWordDocumentButton();
updateMarkerStatusDisplay();
}
};
const patchJson = (url, body) => fetchJson(url, { const patchJson = (url, body) => fetchJson(url, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -3953,6 +4279,7 @@
ensureAnchorControl(sceneParagraph, "PlotDirector Scene", `PD-SCENE-${resolvedSceneId}`, "PD-SCENE"); ensureAnchorControl(sceneParagraph, "PlotDirector Scene", `PD-SCENE-${resolvedSceneId}`, "PD-SCENE");
await context.sync(); await context.sync();
}); });
await inspectPlotDirectorMarkers().catch(() => null);
detectedChapterAnchorId = resolvedChapterId; detectedChapterAnchorId = resolvedChapterId;
detectedSceneAnchorId = resolvedSceneId; detectedSceneAnchorId = resolvedSceneId;
@ -4005,6 +4332,7 @@
ensureAnchorControl(chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER"); ensureAnchorControl(chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER");
await context.sync(); await context.sync();
}); });
await inspectPlotDirectorMarkers().catch(() => null);
detectedChapterAnchorId = resolvedChapterId; detectedChapterAnchorId = resolvedChapterId;
storedAnchorInvalid = false; storedAnchorInvalid = false;
@ -5701,6 +6029,10 @@
saveSceneLinksButton?.addEventListener("click", saveSceneLinks); saveSceneLinksButton?.addEventListener("click", saveSceneLinks);
attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds); attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds);
unlinkWordDocumentButton?.addEventListener("click", unlinkThisWordDocument); unlinkWordDocumentButton?.addEventListener("click", unlinkThisWordDocument);
cleanUnlinkWordDocumentButton?.addEventListener("click", cleanAndUnlinkWordDocument);
openWritingWorkspaceButton?.addEventListener("click", () => openWritingWindow("workspace"));
openWritingContextButton?.addEventListener("click", () => openWritingWindow("context"));
openWritingIntentButton?.addEventListener("click", () => openWritingWindow("intent"));
createPlotDirectorChapterButton?.addEventListener("click", createPlotDirectorChapter); createPlotDirectorChapterButton?.addEventListener("click", createPlotDirectorChapter);
linkExistingChapterButton?.addEventListener("click", openLinkExistingChapterDialog); linkExistingChapterButton?.addEventListener("click", openLinkExistingChapterDialog);
linkChapterSearch?.addEventListener("input", renderLinkChapterOptions); linkChapterSearch?.addEventListener("input", renderLinkChapterOptions);
@ -5760,6 +6092,7 @@
setDocumentMessage("Word document access is unavailable."); setDocumentMessage("Word document access is unavailable.");
setSceneTrackingState("Manual"); setSceneTrackingState("Manual");
} else { } else {
await inspectPlotDirectorMarkers().catch(() => null);
setupSelectionTracking(); setupSelectionTracking();
} }
}) })

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,17 @@
(() => {
window.PlotDirectorWritingFollow?.initialize({
rootSelector: "[data-writing-context]",
contentSelector: "[data-writing-context-content]",
fragmentSelector: "[data-writing-context-fragment]",
statusTextSelector: "[data-writing-context-status-text]",
tabSelector: "[data-writing-context-tab]",
panelSelector: "[data-writing-context-panel]",
selectSelector: "[data-writing-context-scene-select]",
tabDataKey: "writingContextTab",
panelDataKey: "writingContextPanel",
storageKey: "plotdirector.writingContext.activeTab",
defaultTab: "overview",
defaultEndpoint: "/Writing/Context/Scene",
source: "WritingContext"
});
})();

View File

@ -0,0 +1,251 @@
(() => {
const defaults = {
rootSelector: "",
contentSelector: "",
fragmentSelector: "",
statusTextSelector: "",
tabSelector: "",
panelSelector: "",
selectSelector: "",
tabDataKey: "",
panelDataKey: "",
storageKey: "",
defaultTab: "",
defaultEndpoint: "",
source: "WritingMode",
allowChapterOnly: false,
waitingStatus: "Connected, waiting for scene",
updatingStatus: "Updating",
disconnectedStatus: "Word not connected"
};
const parsePositiveInt = (value) => {
const parsed = Number.parseInt(value || "", 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const initialize = (options) => {
const config = { ...defaults, ...options };
const root = document.querySelector(config.rootSelector);
if (!root) {
return null;
}
const contentHost = root.querySelector(config.contentSelector);
const statusText = root.querySelector(config.statusTextSelector);
const endpoint = root.dataset.contextUrl || config.defaultEndpoint;
const scopedProjectId = parsePositiveInt(root.dataset.scopeProjectId);
const scopedBookId = parsePositiveInt(root.dataset.scopeBookId);
let currentFragment = contentHost?.querySelector(config.fragmentSelector);
let currentSceneId = parsePositiveInt(currentFragment?.dataset.sceneId);
let currentChapterId = parsePositiveInt(currentFragment?.dataset.chapterId);
let loadingSceneId = null;
let loadingChapterId = null;
const setStatus = (value) => {
if (statusText && value) {
statusText.textContent = value;
}
};
const registerWorkspaceRole = () => {
const role = config.source === "WritingContext"
? window.PlotDirectorWritingWorkspace?.roles?.context
: config.source === "WritingIntent"
? window.PlotDirectorWritingWorkspace?.roles?.intent
: null;
if (!role || !window.PlotDirectorWritingWorkspace?.registerWindow) {
return;
}
const fragment = contentHost?.querySelector(config.fragmentSelector);
void window.PlotDirectorWritingWorkspace.registerWindow({
projectID: fragment?.dataset.projectId || root.dataset.scopeProjectId,
bookID: fragment?.dataset.bookId || root.dataset.scopeBookId,
writingRole: role,
displayLabel: config.source === "WritingContext" ? "Writing Context" : "Writing Intent"
}).catch(() => null);
};
const readStoredTab = () => {
try {
return localStorage.getItem(config.storageKey) || config.defaultTab;
} catch {
return config.defaultTab;
}
};
const activeTab = () => {
const active = contentHost?.querySelector(`${config.tabSelector}.active`)?.dataset[config.tabDataKey];
return active || readStoredTab();
};
const activateTab = (tabName) => {
const nextTab = tabName || config.defaultTab;
contentHost?.querySelectorAll(config.tabSelector).forEach((button) => {
const active = button.dataset[config.tabDataKey] === nextTab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
contentHost?.querySelectorAll(config.panelSelector).forEach((panel) => {
const active = panel.dataset[config.panelDataKey] === nextTab;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
try {
localStorage.setItem(config.storageKey, nextTab);
} catch {
// Remembering the selected tab is a convenience only.
}
};
const wireFragment = () => {
activateTab(activeTab());
const fragment = contentHost?.querySelector(config.fragmentSelector);
const fragmentStatus = fragment?.dataset.followStatus;
if (fragmentStatus) {
setStatus(fragmentStatus);
}
registerWorkspaceRole();
const fragmentSceneId = parsePositiveInt(fragment?.dataset.sceneId);
if (fragmentSceneId) {
currentSceneId = fragmentSceneId;
}
const fragmentChapterId = parsePositiveInt(fragment?.dataset.chapterId);
if (fragmentChapterId) {
currentChapterId = fragmentChapterId;
}
contentHost?.querySelectorAll(config.tabSelector).forEach((button) => {
button.addEventListener("click", () => activateTab(button.dataset[config.tabDataKey]));
});
contentHost?.querySelector(config.selectSelector)?.addEventListener("change", (event) => {
const sceneId = parsePositiveInt(event.target.value);
if (!sceneId) {
return;
}
const fragment = contentHost?.querySelector(config.fragmentSelector);
window.PlotDirectorStoryPosition?.publishChanged({
source: config.source,
projectId: fragment?.dataset.projectId,
bookId: fragment?.dataset.bookId,
chapterId: fragment?.dataset.chapterId,
sceneId
}, config.source);
});
contentHost?.querySelector("[data-writing-intent-chapter-select]")?.addEventListener("change", (event) => {
const chapterId = parsePositiveInt(event.target.value);
if (!chapterId) {
return;
}
const fragment = contentHost?.querySelector(config.fragmentSelector);
window.PlotDirectorStoryPosition?.publishChanged({
source: config.source,
projectId: fragment?.dataset.projectId,
bookId: fragment?.dataset.bookId,
chapterId
}, config.source);
});
};
const inScope = (position) => {
if (scopedProjectId && position.projectId !== scopedProjectId) {
return false;
}
if (scopedBookId && position.bookId && position.bookId !== scopedBookId) {
return false;
}
return true;
};
const loadPosition = async (position) => {
const hasScene = Number.isInteger(position?.sceneId) && position.sceneId > 0;
const hasChapter = Number.isInteger(position?.chapterId) && position.chapterId > 0;
if (!position || !inScope(position) || (!hasScene && (!config.allowChapterOnly || !hasChapter))) {
return;
}
if (hasScene && (position.sceneId === currentSceneId || position.sceneId === loadingSceneId)) {
return;
}
if (!hasScene && (position.chapterId === currentChapterId || position.chapterId === loadingChapterId)) {
return;
}
const tab = activeTab();
loadingSceneId = hasScene ? position.sceneId : null;
loadingChapterId = !hasScene ? position.chapterId : null;
setStatus(config.updatingStatus);
const url = new URL(endpoint, window.location.origin);
if (position.projectId) url.searchParams.set("projectId", position.projectId);
if (position.bookId) url.searchParams.set("bookId", position.bookId);
if (position.chapterId) url.searchParams.set("chapterId", position.chapterId);
if (hasScene) url.searchParams.set("sceneId", position.sceneId);
try {
const response = await fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } });
if (!response.ok) {
throw new Error(`${config.source} request failed: ${response.status}`);
}
contentHost.innerHTML = await response.text();
if (hasScene) {
currentSceneId = position.sceneId;
}
if (hasChapter) {
currentChapterId = position.chapterId;
}
wireFragment();
activateTab(tab);
} catch (error) {
console.debug(`${config.source} could not update.`, error);
setStatus(config.waitingStatus);
} finally {
loadingSceneId = null;
loadingChapterId = null;
}
};
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => {
void loadPosition(event.detail);
});
if (window.signalR) {
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/word-companion-follow")
.withAutomaticReconnect()
.build();
connection.on("storyPositionChanged", (payload) => {
window.PlotDirectorStoryPosition?.publishChanged(payload, payload?.source || payload?.Source || "WordCompanion");
});
connection.start()
.then(() => {
if (!currentSceneId) {
setStatus(config.waitingStatus);
}
})
.catch((error) => {
console.debug(`${config.source} follow connection unavailable.`, error);
setStatus(config.disconnectedStatus);
});
} else {
setStatus(config.disconnectedStatus);
}
wireFragment();
return { loadPosition, activateTab };
};
window.PlotDirectorWritingFollow = { initialize };
})();

View File

@ -0,0 +1,18 @@
(() => {
window.PlotDirectorWritingFollow?.initialize({
rootSelector: "[data-writing-intent]",
contentSelector: "[data-writing-intent-content]",
fragmentSelector: "[data-writing-intent-fragment]",
statusTextSelector: "[data-writing-intent-status-text]",
tabSelector: "[data-writing-intent-tab]",
panelSelector: "[data-writing-intent-panel]",
selectSelector: "[data-writing-intent-scene-select]",
tabDataKey: "writingIntentTab",
panelDataKey: "writingIntentPanel",
storageKey: "plotdirector.writingIntent.activeTab",
defaultTab: "plan",
defaultEndpoint: "/Writing/Intent/Scene",
source: "WritingIntent",
allowChapterOnly: true
});
})();

View File

@ -0,0 +1,123 @@
(() => {
const root = document.querySelector("[data-writing-workspace-launcher]");
const workspace = window.PlotDirectorWritingWorkspace;
if (!root || !workspace) {
return;
}
const statusNodes = {
book: root.querySelector("[data-writing-workspace-book]"),
word: root.querySelector("[data-writing-workspace-word]"),
context: root.querySelector("[data-writing-workspace-context]"),
intent: root.querySelector("[data-writing-workspace-intent]"),
layout: root.querySelector("[data-writing-workspace-layout]"),
message: root.querySelector("[data-writing-workspace-message]")
};
const startButton = root.querySelector("[data-start-writing-workspace]");
const contextButton = root.querySelector("[data-open-writing-context]");
const intentButton = root.querySelector("[data-open-writing-intent]");
const focusContextButton = root.querySelector("[data-focus-writing-context]");
const focusIntentButton = root.querySelector("[data-focus-writing-intent]");
const fallbackScope = workspace.scopeFrom({
projectID: root.dataset.projectId,
bookID: root.dataset.bookId
});
let activeScope = { ...fallbackScope };
let latestStatus = null;
const text = (node, value) => {
if (node) node.textContent = value;
};
const statusText = (status, role) => {
const match = (status?.windows || []).find((windowPresence) =>
String(windowPresence.writingRole || windowPresence.WritingRole || "").toLowerCase() === role.toLowerCase()
&& (!activeScope.bookID || Number(windowPresence.bookID || windowPresence.BookID) === activeScope.bookID));
if (!match) return "Not open";
const id = match.browserWindowID || match.BrowserWindowID || "";
return id === workspace.getBrowserWindowId() ? "This window" : "Open";
};
const setMessage = (message) => text(statusNodes.message, message || "");
const render = (status = latestStatus) => {
latestStatus = status;
activeScope = workspace.statusScope(status, activeScope);
text(statusNodes.book, activeScope.bookID ? `Book ${activeScope.bookID}` : "Waiting for book");
text(statusNodes.word, status?.wordCompanionConnected || status?.WordCompanionConnected ? "Connected" : "Not connected");
text(statusNodes.context, statusText(status, "Context"));
text(statusNodes.intent, statusText(status, "Intent"));
const contextOpen = statusText(status, "Context") !== "Not open";
const intentOpen = statusText(status, "Intent") !== "Not open";
if (focusContextButton) focusContextButton.hidden = !contextOpen;
if (focusIntentButton) focusIntentButton.hidden = !intentOpen;
if (contextButton) contextButton.textContent = contextOpen ? "Focus Context" : "Open Context";
if (intentButton) intentButton.textContent = intentOpen ? "Focus Intent" : "Open Intent";
};
const refresh = async () => {
try {
render(await workspace.watchWorkspace(activeScope));
} catch (error) {
setMessage("Writing workspace presence is unavailable.");
console.debug("Writing workspace presence unavailable.", error);
}
};
const openOrFocus = async (role) => {
const scope = workspace.statusScope(latestStatus, activeScope);
if (!scope.bookID) {
setMessage("Open a linked Word manuscript or focus a book before opening writing windows.");
return;
}
await workspace.focusRole(role, scope);
await refresh();
};
startButton?.addEventListener("click", async () => {
startButton.disabled = true;
setMessage("Starting writing workspace...");
try {
const result = await workspace.startWorkspace(activeScope, { requestScreens: true });
if (!result.ok) {
setMessage(result.message || `Pop-up blocked for ${result.blocked?.join(", ") || "a writing window"}. Use the explicit open buttons.`);
} else if (result.reusedCurrentWindowAs) {
setMessage("Reusing this window for Writing Intent and opening Context.");
} else {
setMessage("Writing workspace ready.");
}
text(statusNodes.layout, result.screenInfo?.layout?.mode || "single-screen");
await refresh();
} catch (error) {
console.debug("Unable to start writing workspace.", error);
setMessage("Writing workspace could not be started. Use the direct open buttons.");
} finally {
startButton.disabled = false;
}
});
contextButton?.addEventListener("click", () => openOrFocus(workspace.roles.context));
intentButton?.addEventListener("click", () => openOrFocus(workspace.roles.intent));
focusContextButton?.addEventListener("click", () => openOrFocus(workspace.roles.context));
focusIntentButton?.addEventListener("click", () => openOrFocus(workspace.roles.intent));
document.addEventListener("plotdirector:writing-workspace-status", (event) => render(event.detail));
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => {
const position = window.PlotDirectorStoryPosition?.normalise?.(event.detail, event.detail?.source || event.detail?.Source) || event.detail;
if (position?.projectId && position?.bookId) {
activeScope = { projectID: position.projectId, bookID: position.bookId };
void workspace.registerWindow({
...activeScope,
writingRole: workspace.roles.launcher,
displayLabel: "Writing Workspace"
}).then(render).catch(() => null);
}
});
void workspace.registerWindow({
...activeScope,
writingRole: workspace.roles.launcher,
displayLabel: "Writing Workspace"
}).then(render).catch(() => null);
})();

View File

@ -0,0 +1,349 @@
(() => {
const storageKeys = {
windowId: "plotdirector.browserWindowId",
preferences: "plotdirector.writingWorkspace.preferences"
};
const roles = {
none: "None",
launcher: "Launcher",
context: "Context",
intent: "Intent"
};
const windowRefs = new Map();
let connection = null;
let connectionPromise = null;
let heartbeatTimer = null;
let registration = null;
let latestStatus = null;
let latestPosition = null;
const parsePositiveInt = (value) => {
const parsed = Number.parseInt(value || "", 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const getBrowserWindowId = () => {
try {
let value = sessionStorage.getItem(storageKeys.windowId);
if (!value) {
value = window.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
sessionStorage.setItem(storageKeys.windowId, value);
}
return value;
} catch {
return window.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
};
const cleanRole = (role) => {
const value = String(role || "").toLowerCase();
return value === "launcher" ? roles.launcher
: value === "context" ? roles.context
: value === "intent" ? roles.intent
: roles.none;
};
const scopeFrom = (source = {}) => ({
projectID: parsePositiveInt(source.projectID ?? source.projectId ?? source.ProjectID ?? source.ProjectId),
bookID: parsePositiveInt(source.bookID ?? source.bookId ?? source.BookID ?? source.BookId)
});
const scopeMatches = (windowPresence, scope) => {
const projectId = parsePositiveInt(scope?.projectID ?? scope?.projectId);
const bookId = parsePositiveInt(scope?.bookID ?? scope?.bookId);
return (!projectId || parsePositiveInt(windowPresence?.projectID ?? windowPresence?.projectId) === projectId)
&& (!bookId || parsePositiveInt(windowPresence?.bookID ?? windowPresence?.bookId) === bookId);
};
const roleWindow = (status, role, scope) => (status?.windows || [])
.filter((windowPresence) => cleanRole(windowPresence.writingRole ?? windowPresence.WritingRole) === role && scopeMatches(windowPresence, scope))
.sort((a, b) => Date.parse(b.lastSeenUtc || b.LastSeenUtc || 0) - Date.parse(a.lastSeenUtc || a.LastSeenUtc || 0))[0] || null;
const currentRoute = () => `${window.location.pathname}${window.location.search}${window.location.hash}`;
const statusScope = (status, fallback = {}) => ({
projectID: parsePositiveInt(status?.projectID ?? status?.projectId ?? fallback.projectID ?? fallback.projectId),
bookID: parsePositiveInt(status?.bookID ?? status?.bookId ?? fallback.bookID ?? fallback.bookId)
});
const buildWritingUrl = (role, scope) => {
const url = new URL(role === roles.context ? "/Writing/Context" : "/Writing/Intent", window.location.origin);
const projectId = parsePositiveInt(scope?.projectID ?? scope?.projectId);
const bookId = parsePositiveInt(scope?.bookID ?? scope?.bookId);
const position = latestPosition || {};
if (projectId) url.searchParams.set("projectId", projectId);
if (bookId) url.searchParams.set("bookId", bookId);
if (position.chapterId) url.searchParams.set("chapterId", position.chapterId);
if (position.sceneId) url.searchParams.set("sceneId", position.sceneId);
return `${url.pathname}${url.search}${url.hash}`;
};
const stableWindowName = (role, scope) => {
const bookId = parsePositiveInt(scope?.bookID ?? scope?.bookId);
const projectId = parsePositiveInt(scope?.projectID ?? scope?.projectId);
const suffix = bookId ? `Book_${bookId}` : projectId ? `Project_${projectId}` : "General";
return `PlotDirector${role}_${suffix}`.replace(/[^A-Za-z0-9_]/g, "_");
};
const readPreferences = () => {
try {
return JSON.parse(localStorage.getItem(storageKeys.preferences) || "{}") || {};
} catch {
return {};
}
};
const savePreferences = (preferences) => {
try {
localStorage.setItem(storageKeys.preferences, JSON.stringify({ ...readPreferences(), ...preferences }));
} catch {
// Layout memory is a convenience only.
}
};
const sortScreens = (screens) => [...(screens || [])]
.map((screen, index) => ({
index,
left: Number(screen.availLeft ?? screen.left ?? 0),
top: Number(screen.availTop ?? screen.top ?? 0),
width: Math.max(320, Number(screen.availWidth ?? screen.width ?? 1200)),
height: Math.max(320, Number(screen.availHeight ?? screen.height ?? 800)),
isPrimary: Boolean(screen.isPrimary),
isInternal: Boolean(screen.isInternal),
label: screen.label || (screen.isPrimary ? "Primary display" : `Display ${index + 1}`)
}))
.sort((a, b) => a.left - b.left || a.top - b.top || a.index - b.index);
const chooseLayout = (screens, preferences = {}) => {
const ordered = sortScreens(screens);
if (ordered.length >= 3) {
return {
mode: "three-screen",
contextDisplay: preferences.contextDisplay === "right" ? ordered[ordered.length - 1] : ordered[0],
intentDisplay: preferences.intentDisplay === "left" ? ordered[0] : ordered[ordered.length - 1],
wordDisplay: ordered[Math.floor(ordered.length / 2)],
displayCount: ordered.length
};
}
if (ordered.length === 2) {
const primaryRole = preferences.twoScreenPrimary === "intent" ? roles.intent : roles.context;
const secondary = ordered.find((screen) => !screen.isPrimary) || ordered[1];
return {
mode: "two-screen",
contextDisplay: primaryRole === roles.context ? secondary : ordered[0],
intentDisplay: primaryRole === roles.intent ? secondary : ordered[0],
wordDisplay: ordered.find((screen) => screen.isPrimary) || ordered[0],
displayCount: ordered.length
};
}
return {
mode: "single-screen",
contextDisplay: ordered[0] || null,
intentDisplay: ordered[0] || null,
wordDisplay: ordered[0] || null,
displayCount: ordered.length
};
};
const popupFeatures = (display) => {
if (!display) {
return "popup=yes,width=1120,height=900";
}
const width = Math.max(900, Math.floor(display.width * 0.92));
const height = Math.max(760, Math.floor(display.height * 0.92));
const left = Math.floor(display.left + (display.width - width) / 2);
const top = Math.floor(display.top + (display.height - height) / 2);
return `popup=yes,left=${left},top=${top},width=${width},height=${height}`;
};
const getScreenLayout = async (requestPermission) => {
if (!("getScreenDetails" in window) || typeof window.getScreenDetails !== "function") {
return { supported: false, permission: "unavailable", layout: chooseLayout([], readPreferences()) };
}
try {
if (navigator.permissions?.query && !requestPermission) {
const permission = await navigator.permissions.query({ name: "window-management" });
if (permission.state !== "granted") {
return { supported: true, permission: permission.state, layout: chooseLayout([], readPreferences()) };
}
}
const details = await window.getScreenDetails();
const screens = sortScreens(details.screens || []);
return { supported: true, permission: "granted", screens, currentScreen: details.currentScreen, layout: chooseLayout(screens, readPreferences()) };
} catch (error) {
return { supported: true, permission: error?.name === "NotAllowedError" ? "denied" : "unavailable", error, layout: chooseLayout([], readPreferences()) };
}
};
const ensureConnection = async () => {
if (!window.signalR) {
throw new Error("SignalR is unavailable.");
}
if (connectionPromise) {
return connectionPromise;
}
connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/word-companion-follow")
.withAutomaticReconnect()
.build();
connection.on("WritingWorkspacePresenceChanged", (status) => {
latestStatus = status;
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-status", { bubbles: true, detail: status }));
});
connection.on("WordCompanionPresenceChanged", (status) => {
latestStatus = latestStatus ? { ...latestStatus, wordCompanionConnected: Boolean(status?.isConnected ?? status?.IsConnected), wordDocumentOpen: Boolean(status?.documentOpen ?? status?.DocumentOpen) } : latestStatus;
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-word-status", { bubbles: true, detail: status }));
});
connection.on("WritingWorkspaceFocusRequested", () => {
try {
window.focus();
} catch {
// Browser focus restrictions are expected here.
}
});
connection.onreconnected(() => {
if (registration) {
void registerWindow(registration);
}
});
connectionPromise = connection.start().then(() => connection);
return connectionPromise;
};
const invoke = async (method, ...args) => {
const activeConnection = await ensureConnection();
return await activeConnection.invoke(method, ...args);
};
const registerWindow = async (options) => {
registration = {
browserWindowID: getBrowserWindowId(),
projectID: parsePositiveInt(options.projectID ?? options.projectId),
bookID: parsePositiveInt(options.bookID ?? options.bookId),
currentRoute: currentRoute(),
writingRole: cleanRole(options.writingRole || options.role),
displayLabel: options.displayLabel || ""
};
latestStatus = await invoke("RegisterWritingWindow", registration);
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-status", { bubbles: true, detail: latestStatus }));
if (heartbeatTimer) {
window.clearInterval(heartbeatTimer);
}
heartbeatTimer = window.setInterval(() => {
if (!registration) return;
void invoke("WritingWindowHeartbeat", { ...registration, currentRoute: currentRoute() }).catch(() => null);
}, 15000);
return latestStatus;
};
const watchWorkspace = async (scope) => {
latestStatus = await invoke("WatchWritingWorkspace", scopeFrom(scope));
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-status", { bubbles: true, detail: latestStatus }));
return latestStatus;
};
const openRoleWindow = (role, scope, display) => {
const url = buildWritingUrl(role, scope);
const name = stableWindowName(role, scope);
const opened = window.open(url, name, popupFeatures(display));
if (opened) {
windowRefs.set(role, opened);
try {
opened.focus();
} catch {
// Focus is best-effort only.
}
}
return opened;
};
const focusRole = async (role, scope) => {
const ref = windowRefs.get(role);
if (ref && !ref.closed) {
ref.focus();
return true;
}
const opened = window.open(buildWritingUrl(role, scope), stableWindowName(role, scope));
if (opened) {
windowRefs.set(role, opened);
opened.focus();
return true;
}
await invoke("RequestWritingWindowFocus", scopeFrom(scope), role).catch(() => null);
return false;
};
const startWorkspace = async (scope, options = {}) => {
const cleanScope = scopeFrom(scope);
const status = await watchWorkspace(cleanScope);
const resolvedScope = statusScope(status, cleanScope);
if (!resolvedScope.projectID || !resolvedScope.bookID) {
return { ok: false, message: "Choose a book or open a linked Word manuscript before starting the writing workspace.", status };
}
const screenInfo = await getScreenLayout(Boolean(options.requestScreens));
const layout = screenInfo.layout;
const existingContext = roleWindow(status, roles.context, resolvedScope);
const existingIntent = roleWindow(status, roles.intent, resolvedScope);
const currentRole = cleanRole(registration?.writingRole);
const opened = [];
const blocked = [];
if (!existingIntent && currentRole !== roles.context && currentRole !== roles.intent) {
savePreferences({ reuseLauncherAs: roles.intent, lastLayoutMode: layout.mode });
if (!existingContext) {
const contextWindow = openRoleWindow(roles.context, resolvedScope, layout.contextDisplay);
(contextWindow ? opened : blocked).push(roles.context);
}
window.location.assign(buildWritingUrl(roles.intent, resolvedScope));
return { ok: true, reusedCurrentWindowAs: roles.intent, opened, blocked, status, screenInfo };
}
if (!existingContext) {
const contextWindow = openRoleWindow(roles.context, resolvedScope, layout.contextDisplay);
(contextWindow ? opened : blocked).push(roles.context);
} else {
await focusRole(roles.context, resolvedScope);
}
if (!existingIntent) {
const intentWindow = currentRole === roles.intent ? window : openRoleWindow(roles.intent, resolvedScope, layout.intentDisplay);
(intentWindow ? opened : blocked).push(roles.intent);
} else {
await focusRole(roles.intent, resolvedScope);
}
savePreferences({ reuseLauncherAs: roles.intent, lastLayoutMode: layout.mode });
return { ok: blocked.length === 0, opened, blocked, status: await watchWorkspace(resolvedScope), screenInfo };
};
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => {
latestPosition = window.PlotDirectorStoryPosition?.normalise?.(event.detail, event.detail?.source || event.detail?.Source) || event.detail;
});
window.PlotDirectorWritingWorkspace = {
roles,
getBrowserWindowId,
registerWindow,
watchWorkspace,
startWorkspace,
focusRole,
openRoleWindow,
stableWindowName,
buildWritingUrl,
sortScreens,
chooseLayout,
getScreenLayout,
scopeFrom,
statusScope
};
})();