Compare commits
8 Commits
f6b4263f31
...
95f5c2925d
| Author | SHA1 | Date | |
|---|---|---|---|
| 95f5c2925d | |||
| 43a57df070 | |||
| 451e384966 | |||
| 884a96d166 | |||
| fa2ffe4f17 | |||
| bba75f3ac4 | |||
| 90307757b4 | |||
| 9f0fdbc366 |
@ -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"));
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
191
PlotLine/Controllers/WritingController.cs
Normal file
191
PlotLine/Controllers/WritingController.cs
Normal 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()
|
||||||
|
?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
PlotLine/Models/StoryPosition.cs
Normal file
59
PlotLine/Models/StoryPosition.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
76
PlotLine/Models/WritingWorkspacePresenceModels.cs
Normal file
76
PlotLine/Models/WritingWorkspacePresenceModels.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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>();
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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('‘', '\'');
|
||||||
|
|||||||
217
PlotLine/Services/WritingWorkspacePresenceService.cs
Normal file
217
PlotLine/Services/WritingWorkspacePresenceService.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
@ -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; }
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
}
|
||||||
|
|||||||
45
PlotLine/Views/Writing/Context.cshtml
Normal file
45
PlotLine/Views/Writing/Context.cshtml
Normal 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>
|
||||||
|
}
|
||||||
45
PlotLine/Views/Writing/Intent.cshtml
Normal file
45
PlotLine/Views/Writing/Intent.cshtml
Normal 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>
|
||||||
|
}
|
||||||
74
PlotLine/Views/Writing/_WritingContextContent.cshtml
Normal file
74
PlotLine/Views/Writing/_WritingContextContent.cshtml
Normal 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>
|
||||||
74
PlotLine/Views/Writing/_WritingContextOverview.cshtml
Normal file
74
PlotLine/Views/Writing/_WritingContextOverview.cshtml
Normal 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>
|
||||||
53
PlotLine/Views/Writing/_WritingContextPeople.cshtml
Normal file
53
PlotLine/Views/Writing/_WritingContextPeople.cshtml
Normal 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>
|
||||||
|
}
|
||||||
95
PlotLine/Views/Writing/_WritingContextPlace.cshtml
Normal file
95
PlotLine/Views/Writing/_WritingContextPlace.cshtml
Normal 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>
|
||||||
58
PlotLine/Views/Writing/_WritingContextRelationships.cshtml
Normal file
58
PlotLine/Views/Writing/_WritingContextRelationships.cshtml
Normal 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>
|
||||||
96
PlotLine/Views/Writing/_WritingContextStory.cshtml
Normal file
96
PlotLine/Views/Writing/_WritingContextStory.cshtml
Normal 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>
|
||||||
53
PlotLine/Views/Writing/_WritingIntentChecklist.cshtml
Normal file
53
PlotLine/Views/Writing/_WritingIntentChecklist.cshtml
Normal 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>
|
||||||
111
PlotLine/Views/Writing/_WritingIntentContent.cshtml
Normal file
111
PlotLine/Views/Writing/_WritingIntentContent.cshtml
Normal 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>
|
||||||
42
PlotLine/Views/Writing/_WritingIntentMustInclude.cshtml
Normal file
42
PlotLine/Views/Writing/_WritingIntentMustInclude.cshtml
Normal 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>
|
||||||
36
PlotLine/Views/Writing/_WritingIntentNotes.cshtml
Normal file
36
PlotLine/Views/Writing/_WritingIntentNotes.cshtml
Normal 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>
|
||||||
53
PlotLine/Views/Writing/_WritingIntentPlan.cshtml
Normal file
53
PlotLine/Views/Writing/_WritingIntentPlan.cshtml
Normal 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>
|
||||||
29
PlotLine/Views/Writing/_WritingIntentThreads.cshtml
Normal file
29
PlotLine/Views/Writing/_WritingIntentThreads.cshtml
Normal 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>
|
||||||
@ -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;
|
||||||
|
|||||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
2
PlotLine/wwwroot/css/word-companion.min.css
vendored
2
PlotLine/wwwroot/css/word-companion.min.css
vendored
File diff suppressed because one or more lines are too long
@ -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) {
|
||||||
|
|||||||
2
PlotLine/wwwroot/js/site.min.js
vendored
2
PlotLine/wwwroot/js/site.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
26
PlotLine/wwwroot/js/word-companion-host.min.js
vendored
26
PlotLine/wwwroot/js/word-companion-host.min.js
vendored
File diff suppressed because one or more lines are too long
17
PlotLine/wwwroot/js/writing-context.js
Normal file
17
PlotLine/wwwroot/js/writing-context.js
Normal 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"
|
||||||
|
});
|
||||||
|
})();
|
||||||
251
PlotLine/wwwroot/js/writing-follow.js
Normal file
251
PlotLine/wwwroot/js/writing-follow.js
Normal 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 };
|
||||||
|
})();
|
||||||
18
PlotLine/wwwroot/js/writing-intent.js
Normal file
18
PlotLine/wwwroot/js/writing-intent.js
Normal 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
|
||||||
|
});
|
||||||
|
})();
|
||||||
123
PlotLine/wwwroot/js/writing-workspace-launcher.js
Normal file
123
PlotLine/wwwroot/js/writing-workspace-launcher.js
Normal 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);
|
||||||
|
})();
|
||||||
349
PlotLine/wwwroot/js/writing-workspace.js
Normal file
349
PlotLine/wwwroot/js/writing-workspace.js
Normal 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
|
||||||
|
};
|
||||||
|
})();
|
||||||
Loading…
x
Reference in New Issue
Block a user