diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index af27598..1042f6a 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -81,6 +81,11 @@ var tests = new (string Name, Action Test)[] ("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), @@ -269,6 +274,134 @@ static void WritingPairSharesCanonicalFollowInfrastructure() 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"); diff --git a/PlotLine/Hubs/WordCompanionFollowHub.cs b/PlotLine/Hubs/WordCompanionFollowHub.cs index ddab6b8..cba30eb 100644 --- a/PlotLine/Hubs/WordCompanionFollowHub.cs +++ b/PlotLine/Hubs/WordCompanionFollowHub.cs @@ -10,6 +10,7 @@ namespace PlotLine.Hubs; [Authorize] public sealed class WordCompanionFollowHub( IWordCompanionPresenceService presence, + IWritingWorkspacePresenceService writingPresence, IOnboardingRepository onboarding, IManuscriptScanPreviewStore scanStore, IOnboardingService onboardingService) : Hub @@ -41,6 +42,7 @@ public sealed class WordCompanionFollowHub( var status = await presence.RegisterAsync(userId, Context.ConnectionId, registration); await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status); + await BroadcastWritingWorkspaceStatusAsync(userId, registration.LinkedProjectID, registration.LinkedBookID); return status; } @@ -49,9 +51,51 @@ public sealed class WordCompanionFollowHub( var userId = RequireUserId(); var status = await presence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat); await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status); + await BroadcastWritingWorkspaceStatusAsync(userId, heartbeat.LinkedProjectID, heartbeat.LinkedBookID); return status; } + public async Task WatchWritingWorkspace(WritingWorkspaceScope scope) + { + var userId = RequireUserId(); + await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId)); + return await WritingWorkspaceStatusAsync(userId, scope); + } + + public async Task 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 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 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 StartOnboardingManuscriptScan() { var userId = RequireUserId(); @@ -163,10 +207,13 @@ public sealed class WordCompanionFollowHub( public override async Task OnDisconnectedAsync(Exception? exception) { + int? disconnectedUserId = null; var status = await presence.DisconnectAsync(Context.ConnectionId); if (status is not null) { + disconnectedUserId = status.UserID; await Clients.Group(PresenceGroup(status.UserID)).SendAsync("WordCompanionPresenceChanged", status); + await BroadcastWritingWorkspaceStatusAsync(status.UserID, status.LinkedProjectID, status.LinkedBookID); var failedScans = await scanStore.FailRunningForUserAsync( status.UserID, "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); } + private async Task 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() => TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use the Word Companion."); diff --git a/PlotLine/Models/WritingWorkspacePresenceModels.cs b/PlotLine/Models/WritingWorkspacePresenceModels.cs new file mode 100644 index 0000000..8ac92a2 --- /dev/null +++ b/PlotLine/Models/WritingWorkspacePresenceModels.cs @@ -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 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; +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index d0bffdf..131f7b2 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -195,6 +195,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/PlotLine/Services/WordCompanionPresenceMonitor.cs b/PlotLine/Services/WordCompanionPresenceMonitor.cs index f4b559b..390d086 100644 --- a/PlotLine/Services/WordCompanionPresenceMonitor.cs +++ b/PlotLine/Services/WordCompanionPresenceMonitor.cs @@ -1,10 +1,12 @@ using Microsoft.AspNetCore.SignalR; using PlotLine.Hubs; +using PlotLine.Models; namespace PlotLine.Services; public sealed class WordCompanionPresenceMonitor( IWordCompanionPresenceService presence, + IWritingWorkspacePresenceService writingPresence, IManuscriptScanPreviewStore scanStore, IHubContext hub) : BackgroundService { @@ -21,6 +23,14 @@ public sealed class WordCompanionPresenceMonitor( await hub.Clients .Group(PresenceGroup(status.UserID)) .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( status.UserID, "The Word Companion disconnected before the scan finished. Reopen Word and try again."); @@ -29,10 +39,18 @@ public sealed class WordCompanionPresenceMonitor( await hub.Clients .Group(PresenceGroup(status.UserID)) .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) { } diff --git a/PlotLine/Services/WritingWorkspacePresenceService.cs b/PlotLine/Services/WritingWorkspacePresenceService.cs new file mode 100644 index 0000000..d6de055 --- /dev/null +++ b/PlotLine/Services/WritingWorkspacePresenceService.cs @@ -0,0 +1,217 @@ +using System.Collections.Concurrent; +using PlotLine.Models; + +namespace PlotLine.Services; + +public interface IWritingWorkspacePresenceService +{ + Task RegisterAsync(int userId, string connectionId, WritingWindowRegistration registration, WordCompanionPresenceStatus wordStatus); + Task HeartbeatAsync(int userId, string connectionId, WritingWindowHeartbeat heartbeat, WordCompanionPresenceStatus wordStatus); + Task DisconnectAsync(string connectionId, WordCompanionPresenceStatus? wordStatus); + Task GetStatusAsync(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus); + Task> MarkStaleOfflineAsync(Func> wordStatusForUser); + Task GetConnectionIdForRoleAsync(int userId, WritingWorkspaceScope scope, string role); +} + +public sealed class WritingWorkspacePresenceService : IWritingWorkspacePresenceService +{ + private static readonly TimeSpan ActiveTimeout = TimeSpan.FromSeconds(45); + private readonly ConcurrentDictionary windowsById = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary windowIdsByConnection = new(StringComparer.Ordinal); + + public Task 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 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 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(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(status); + } + + public Task GetStatusAsync(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus) + => Task.FromResult(BuildStatus(userId, scope, wordStatus)); + + public async Task> MarkStaleOfflineAsync(Func> wordStatusForUser) + { + var now = DateTime.UtcNow; + var changedScopes = new Dictionary(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(); + 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 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 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 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; } + } +} diff --git a/PlotLine/Views/WordCompanionHost/Index.cshtml b/PlotLine/Views/WordCompanionHost/Index.cshtml index 5933097..4c90b02 100644 --- a/PlotLine/Views/WordCompanionHost/Index.cshtml +++ b/PlotLine/Views/WordCompanionHost/Index.cshtml @@ -101,6 +101,17 @@

Linked to PlotDirector

+
+
+ Writing Workspace +
+
+ + + +
+
+