Phase 20D Implement website-led manuscript scanning via the Word Companion.

This commit is contained in:
Nick Beckley 2026-07-03 22:20:14 +01:00
parent 6f41605f08
commit a70527f3a4
15 changed files with 1239 additions and 11 deletions

View File

@ -17,6 +17,13 @@ public sealed class OnboardingController(IOnboardingService onboarding) : Contro
return View(model);
}
[HttpGet("scan-review")]
public async Task<IActionResult> ScanReview(Guid? previewId)
{
var model = await onboarding.GetScanReviewAsync(previewId);
return model is null ? NotFound() : View(model);
}
[HttpPost("welcome")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Welcome()

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using System.Security.Claims;
@ -7,7 +8,10 @@ using System.Security.Claims;
namespace PlotLine.Hubs;
[Authorize]
public sealed class WordCompanionFollowHub(IWordCompanionPresenceService presence) : Hub
public sealed class WordCompanionFollowHub(
IWordCompanionPresenceService presence,
IOnboardingRepository onboarding,
IManuscriptScanPreviewStore scanStore) : Hub
{
public override async Task OnConnectedAsync()
{
@ -47,12 +51,94 @@ public sealed class WordCompanionFollowHub(IWordCompanionPresenceService presenc
return status;
}
public async Task<ManuscriptScanState> StartOnboardingManuscriptScan()
{
var userId = RequireUserId();
var state = await onboarding.GetAsync(userId);
if (state is null
|| !IsMicrosoftWordState(state)
|| !state.ProjectID.HasValue
|| !state.BookID.HasValue)
{
throw new HubException("Finish choosing a Project and Book before scanning.");
}
var presenceStatus = await presence.GetStatusAsync(userId);
if (!presenceStatus.IsConnected)
{
throw new HubException("The Word Companion is not connected. Open Word and try again.");
}
if (!presenceStatus.DocumentOpen)
{
throw new HubException("Open your manuscript in Word before scanning.");
}
var companionConnectionId = await presence.GetCompanionConnectionIdAsync(userId);
if (string.IsNullOrWhiteSpace(companionConnectionId))
{
throw new HubException("The Word Companion connection is not ready. Try reconnecting the Companion.");
}
var scanState = await scanStore.StartAsync(userId, state.UserOnboardingStateID, state.ProjectID.Value, state.BookID.Value);
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanStateChanged", scanState);
await Clients.Client(companionConnectionId).SendAsync("ScanCurrentDocument", new ManuscriptScanCommand
{
UserID = userId,
OnboardingID = state.UserOnboardingStateID,
ProjectID = state.ProjectID.Value,
BookID = state.BookID.Value,
RequestedUtc = DateTime.UtcNow
});
return scanState;
}
public async Task<ManuscriptScanState> ReportOnboardingScanProgress(ManuscriptScanProgress progress)
{
var userId = RequireUserId();
var scanState = await scanStore.ProgressAsync(userId, progress);
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanProgress", scanState);
return scanState;
}
public async Task<ManuscriptScanState> CompleteOnboardingScan(ManuscriptScanPreview preview)
{
var userId = RequireUserId();
var state = await onboarding.GetAsync(userId);
if (state is null
|| state.UserOnboardingStateID != preview.OnboardingID
|| state.ProjectID != preview.ProjectID
|| state.BookID != preview.BookID)
{
throw new HubException("The scan result does not match your onboarding setup.");
}
var scanState = await scanStore.CompleteAsync(userId, preview);
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanCompleted", scanState);
return scanState;
}
public async Task<ManuscriptScanState> FailOnboardingScan(ManuscriptScanFailure failure)
{
var userId = RequireUserId();
var scanState = await scanStore.FailAsync(userId, failure.OnboardingID, failure.Message);
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanFailed", scanState);
return scanState;
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var status = await presence.DisconnectAsync(Context.ConnectionId);
if (status is not null)
{
await Clients.Group(PresenceGroup(status.UserID)).SendAsync("WordCompanionPresenceChanged", status);
var failedScans = await scanStore.FailRunningForUserAsync(
status.UserID,
"The Word Companion disconnected before the scan finished. Reopen Word and try again.");
foreach (var failedScan in failedScans)
{
await Clients.Group(PresenceGroup(status.UserID)).SendAsync("OnboardingScanFailed", failedScan);
}
}
await base.OnDisconnectedAsync(exception);
@ -65,4 +151,8 @@ public sealed class WordCompanionFollowHub(IWordCompanionPresenceService presenc
=> int.TryParse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier), out userId);
private static string PresenceGroup(int userId) => $"word-companion-presence:{userId}";
private static bool IsMicrosoftWordState(UserOnboardingState state)
=> string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal)
|| state.UsesWordCompanion == true;
}

View File

@ -0,0 +1,105 @@
namespace PlotLine.Models;
public static class ManuscriptScanSources
{
public const string WordCompanion = "WordCompanion";
}
public static class ManuscriptScanStatuses
{
public const string NotStarted = "NotStarted";
public const string Running = "Running";
public const string Complete = "Complete";
public const string Failed = "Failed";
}
public sealed class ManuscriptScanCommand
{
public int UserID { get; init; }
public int OnboardingID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public DateTime RequestedUtc { get; init; }
}
public sealed class ManuscriptScanProgress
{
public int? UserID { get; init; }
public int OnboardingID { get; init; }
public string Message { get; init; } = string.Empty;
public int? PercentComplete { get; init; }
public int ChapterCount { get; init; }
public int SceneCount { get; init; }
public int CharacterCandidateCount { get; init; }
public int TotalWordCount { get; init; }
}
public sealed class ManuscriptScanFailure
{
public int? UserID { get; init; }
public int OnboardingID { get; init; }
public string Message { get; init; } = string.Empty;
}
public sealed class ManuscriptScanPreview
{
public Guid PreviewID { get; init; } = Guid.NewGuid();
public int UserID { get; init; }
public int OnboardingID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string Source { get; init; } = ManuscriptScanSources.WordCompanion;
public string? DocumentTitle { get; init; }
public string? CompanionDocumentIdentifier { get; init; }
public int TotalWordCount { get; init; }
public int ChapterCount { get; init; }
public int SceneCount { get; init; }
public int CharacterCandidateCount { get; init; }
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
public IReadOnlyList<ManuscriptScanChapterPreview> Chapters { get; init; } = [];
public IReadOnlyList<ManuscriptScanScenePreview> Scenes { get; init; } = [];
public IReadOnlyList<ManuscriptScanCharacterCandidatePreview> CharacterCandidates { get; init; } = [];
}
public sealed class ManuscriptScanChapterPreview
{
public string TemporaryChapterKey { get; init; } = string.Empty;
public int ChapterNumber { get; init; }
public string Title { get; init; } = string.Empty;
public int WordCount { get; init; }
public int? StartPosition { get; init; }
public int? ExistingChapterID { get; init; }
}
public sealed class ManuscriptScanScenePreview
{
public string TemporarySceneKey { get; init; } = string.Empty;
public string TemporaryChapterKey { get; init; } = string.Empty;
public int SceneNumberWithinChapter { get; init; }
public string? Title { get; init; }
public int WordCount { get; init; }
public string? OpeningTextPreview { get; init; }
public int? StartPosition { get; init; }
public int? ExistingSceneID { get; init; }
}
public sealed class ManuscriptScanCharacterCandidatePreview
{
public string TemporaryCharacterKey { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public int MentionCount { get; init; }
public string? SuggestedImportance { get; init; }
}
public sealed class ManuscriptScanState
{
public string Status { get; init; } = ManuscriptScanStatuses.NotStarted;
public string Message { get; init; } = string.Empty;
public int? PercentComplete { get; init; }
public int ChapterCount { get; init; }
public int SceneCount { get; init; }
public int CharacterCandidateCount { get; init; }
public int TotalWordCount { get; init; }
public Guid? PreviewID { get; init; }
public DateTime? UpdatedUtc { get; init; }
}

View File

@ -171,6 +171,7 @@ public class Program
builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<IWordCompanionService, WordCompanionService>();
builder.Services.AddSingleton<IWordCompanionPresenceService, WordCompanionPresenceService>();
builder.Services.AddSingleton<IManuscriptScanPreviewStore, ManuscriptScanPreviewStore>();
builder.Services.AddHostedService<WordCompanionPresenceMonitor>();
builder.Services.AddScoped<IOnboardingService, OnboardingService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();

View File

@ -0,0 +1,195 @@
using System.Collections.Concurrent;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IManuscriptScanPreviewStore
{
Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId);
Task<ManuscriptScanState> ProgressAsync(int userId, ManuscriptScanProgress progress);
Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview);
Task<ManuscriptScanState> FailAsync(int userId, int onboardingId, string message);
Task<IReadOnlyList<ManuscriptScanState>> FailRunningForUserAsync(int userId, string message);
Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId);
Task<ManuscriptScanPreview?> GetLatestPreviewAsync(int userId, int onboardingId);
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
}
public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
{
private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new();
private readonly ConcurrentDictionary<Guid, ManuscriptScanPreview> previews = new();
public Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId)
{
var session = new ScanSession
{
UserID = userId,
OnboardingID = onboardingId,
ProjectID = projectId,
BookID = bookId,
Status = ManuscriptScanStatuses.Running,
Message = "Preparing document scan",
PercentComplete = 0,
UpdatedUtc = DateTime.UtcNow
};
sessions[(userId, onboardingId)] = session;
return Task.FromResult(ToState(session));
}
public Task<ManuscriptScanState> ProgressAsync(int userId, ManuscriptScanProgress progress)
{
var key = (userId, progress.OnboardingID);
var session = sessions.GetOrAdd(key, _ => new ScanSession
{
UserID = userId,
OnboardingID = progress.OnboardingID,
Status = ManuscriptScanStatuses.Running
});
session.Status = ManuscriptScanStatuses.Running;
session.Message = Clean(progress.Message, "Scanning manuscript");
session.PercentComplete = progress.PercentComplete;
session.ChapterCount = progress.ChapterCount;
session.SceneCount = progress.SceneCount;
session.CharacterCandidateCount = progress.CharacterCandidateCount;
session.TotalWordCount = progress.TotalWordCount;
session.UpdatedUtc = DateTime.UtcNow;
return Task.FromResult(ToState(session));
}
public Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview)
{
var stored = preview.WithUser(userId);
previews[stored.PreviewID] = stored;
var session = sessions.GetOrAdd((userId, stored.OnboardingID), _ => new ScanSession
{
UserID = userId,
OnboardingID = stored.OnboardingID,
ProjectID = stored.ProjectID,
BookID = stored.BookID
});
session.Status = ManuscriptScanStatuses.Complete;
session.Message = "Scan complete";
session.PercentComplete = 100;
session.ChapterCount = stored.ChapterCount;
session.SceneCount = stored.SceneCount;
session.CharacterCandidateCount = stored.CharacterCandidateCount;
session.TotalWordCount = stored.TotalWordCount;
session.PreviewID = stored.PreviewID;
session.UpdatedUtc = stored.CreatedUtc;
return Task.FromResult(ToState(session));
}
public Task<ManuscriptScanState> FailAsync(int userId, int onboardingId, string message)
{
var session = sessions.GetOrAdd((userId, onboardingId), _ => new ScanSession
{
UserID = userId,
OnboardingID = onboardingId
});
session.Status = ManuscriptScanStatuses.Failed;
session.Message = Clean(message, "The scan could not finish. Try reconnecting the Word Companion.");
session.UpdatedUtc = DateTime.UtcNow;
return Task.FromResult(ToState(session));
}
public Task<IReadOnlyList<ManuscriptScanState>> FailRunningForUserAsync(int userId, string message)
{
var failed = new List<ManuscriptScanState>();
foreach (var pair in sessions)
{
if (pair.Key.UserId != userId || !string.Equals(pair.Value.Status, ManuscriptScanStatuses.Running, StringComparison.Ordinal))
{
continue;
}
pair.Value.Status = ManuscriptScanStatuses.Failed;
pair.Value.Message = Clean(message, "The Word Companion disconnected before the scan finished. Reopen Word and try again.");
pair.Value.UpdatedUtc = DateTime.UtcNow;
failed.Add(ToState(pair.Value));
}
return Task.FromResult<IReadOnlyList<ManuscriptScanState>>(failed);
}
public Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId)
=> Task.FromResult(sessions.TryGetValue((userId, onboardingId), out var session)
? ToState(session)
: new ManuscriptScanState());
public Task<ManuscriptScanPreview?> GetLatestPreviewAsync(int userId, int onboardingId)
{
if (!sessions.TryGetValue((userId, onboardingId), out var session) || !session.PreviewID.HasValue)
{
return Task.FromResult<ManuscriptScanPreview?>(null);
}
return GetPreviewAsync(userId, session.PreviewID.Value);
}
public Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId)
=> Task.FromResult(previews.TryGetValue(previewId, out var preview) && preview.UserID == userId ? preview : null);
private static ManuscriptScanState ToState(ScanSession session)
=> new()
{
Status = session.Status,
Message = session.Message,
PercentComplete = session.PercentComplete,
ChapterCount = session.ChapterCount,
SceneCount = session.SceneCount,
CharacterCandidateCount = session.CharacterCandidateCount,
TotalWordCount = session.TotalWordCount,
PreviewID = session.PreviewID,
UpdatedUtc = session.UpdatedUtc
};
private static string Clean(string? value, string fallback)
{
var cleaned = (value ?? string.Empty).Trim();
return string.IsNullOrWhiteSpace(cleaned) ? fallback : cleaned;
}
private sealed class ScanSession
{
public int UserID { get; init; }
public int OnboardingID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string Status { get; set; } = ManuscriptScanStatuses.NotStarted;
public string Message { get; set; } = string.Empty;
public int? PercentComplete { get; set; }
public int ChapterCount { get; set; }
public int SceneCount { get; set; }
public int CharacterCandidateCount { get; set; }
public int TotalWordCount { get; set; }
public Guid? PreviewID { get; set; }
public DateTime? UpdatedUtc { get; set; }
}
}
file static class ManuscriptScanPreviewExtensions
{
public static ManuscriptScanPreview WithUser(this ManuscriptScanPreview preview, int userId)
=> new()
{
PreviewID = preview.PreviewID == Guid.Empty ? Guid.NewGuid() : preview.PreviewID,
UserID = userId,
OnboardingID = preview.OnboardingID,
ProjectID = preview.ProjectID,
BookID = preview.BookID,
Source = preview.Source,
DocumentTitle = preview.DocumentTitle,
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount,
SceneCount = preview.SceneCount,
CharacterCandidateCount = preview.CharacterCandidateCount,
CreatedUtc = preview.CreatedUtc == default ? DateTime.UtcNow : preview.CreatedUtc,
Chapters = preview.Chapters,
Scenes = preview.Scenes,
CharacterCandidates = preview.CharacterCandidates
};
}

View File

@ -8,6 +8,7 @@ public interface IOnboardingService
{
Task<UserOnboardingState> GetOrCreateAsync();
Task<OnboardingWizardViewModel> GetWizardAsync();
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
Task<bool> ShouldShowOnboardingAsync();
Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
Task<bool> ShouldShowWordCompanionHeaderAsync();
@ -27,6 +28,7 @@ public sealed class OnboardingService(
IBookService bookService,
IProjectActivityService activity,
IWordCompanionPresenceService companionPresence,
IManuscriptScanPreviewStore scanPreviews,
ICurrentUserService currentUser) : IOnboardingService
{
private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal)
@ -64,6 +66,7 @@ public sealed class OnboardingService(
? await books.GetAsync(state.BookID.Value)
: null;
var presence = await companionPresence.GetStatusAsync(RequireUserId());
var scanState = await scanPreviews.GetStateAsync(RequireUserId(), state.UserOnboardingStateID);
return new OnboardingWizardViewModel
{
@ -76,6 +79,7 @@ public sealed class OnboardingService(
SelectedProjectName = selectedProject?.ProjectName,
SelectedBookTitle = selectedBook is null ? null : BookOptionTitle(selectedBook),
CompanionPresence = ToPresenceViewModel(presence),
ScanState = ToScanStateViewModel(scanState),
ProjectOptions = projectOptions,
BookOptions = bookOptions,
ProjectForm = new ProjectOnboardingForm
@ -97,6 +101,64 @@ public sealed class OnboardingService(
};
}
public async Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null)
{
var userId = RequireUserId();
var state = await onboarding.GetAsync(userId);
if (state is null || !IsMicrosoftWordState(state))
{
return null;
}
var preview = previewId.HasValue
? await scanPreviews.GetPreviewAsync(userId, previewId.Value)
: await scanPreviews.GetLatestPreviewAsync(userId, state.UserOnboardingStateID);
if (preview is null
|| preview.OnboardingID != state.UserOnboardingStateID
|| preview.ProjectID != state.ProjectID
|| preview.BookID != state.BookID)
{
return null;
}
var project = await projects.GetForUserAsync(preview.ProjectID, userId);
var book = await books.GetAsync(preview.BookID);
if (project is null || book is null || book.ProjectID != project.ProjectID)
{
return null;
}
return new ManuscriptScanReviewViewModel
{
PreviewID = preview.PreviewID,
DocumentTitle = string.IsNullOrWhiteSpace(preview.DocumentTitle) ? "Word manuscript" : preview.DocumentTitle!,
SelectedProjectName = project.ProjectName,
SelectedBookTitle = BookOptionTitle(book),
TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount,
SceneCount = preview.SceneCount,
CharacterCandidateCount = preview.CharacterCandidateCount,
Chapters = preview.Chapters
.OrderBy(chapter => chapter.ChapterNumber)
.Select(chapter => new ManuscriptScanReviewChapterViewModel
{
TemporaryChapterKey = chapter.TemporaryChapterKey,
ChapterNumber = chapter.ChapterNumber,
Title = chapter.Title,
WordCount = chapter.WordCount,
Scenes = preview.Scenes
.Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal))
.OrderBy(scene => scene.SceneNumberWithinChapter)
.ToList()
})
.ToList(),
CharacterCandidates = preview.CharacterCandidates
.OrderByDescending(candidate => candidate.MentionCount)
.ThenBy(candidate => candidate.Name)
.ToList()
};
}
public async Task<bool> ShouldShowOnboardingAsync()
{
if (!currentUser.UserId.HasValue)
@ -425,12 +487,26 @@ public sealed class OnboardingService(
=> new()
{
IsConnected = status.IsConnected,
DocumentOpen = status.DocumentOpen,
Status = status.Status,
CompanionVersion = status.CompanionVersion,
CurrentDocumentName = status.CurrentDocumentName,
LastHeartbeatUtc = status.LastHeartbeatUtc
};
private static ManuscriptScanStateViewModel ToScanStateViewModel(ManuscriptScanState state)
=> new()
{
Status = state.Status,
Message = state.Message,
PercentComplete = state.PercentComplete,
ChapterCount = state.ChapterCount,
SceneCount = state.SceneCount,
CharacterCandidateCount = state.CharacterCandidateCount,
TotalWordCount = state.TotalWordCount,
PreviewID = state.PreviewID
};
private static string? CleanOptional(string? value)
{
var cleaned = Clean(value);

View File

@ -5,6 +5,7 @@ namespace PlotLine.Services;
public sealed class WordCompanionPresenceMonitor(
IWordCompanionPresenceService presence,
IManuscriptScanPreviewStore scanStore,
IHubContext<WordCompanionFollowHub> hub) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@ -20,6 +21,15 @@ public sealed class WordCompanionPresenceMonitor(
await hub.Clients
.Group(PresenceGroup(status.UserID))
.SendAsync("WordCompanionPresenceChanged", status, stoppingToken);
var failedScans = await scanStore.FailRunningForUserAsync(
status.UserID,
"The Word Companion disconnected before the scan finished. Reopen Word and try again.");
foreach (var failedScan in failedScans)
{
await hub.Clients
.Group(PresenceGroup(status.UserID))
.SendAsync("OnboardingScanFailed", failedScan, stoppingToken);
}
}
}
}

View File

@ -11,6 +11,7 @@ public interface IWordCompanionPresenceService
Task<WordCompanionPresenceStatus> GetStatusAsync(int userId);
Task<bool> IsConnectedAsync(int userId);
Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync();
Task<string?> GetCompanionConnectionIdAsync(int userId);
}
public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
@ -99,6 +100,19 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
public async Task<bool> IsConnectedAsync(int userId)
=> (await GetStatusAsync(userId)).IsConnected;
public Task<string?> GetCompanionConnectionIdAsync(int userId)
{
if (!recordsByUser.TryGetValue(userId, out var record))
{
return Task.FromResult<string?>(null);
}
var status = ToStatus(record, DateTime.UtcNow);
return Task.FromResult(status.IsConnected && !string.IsNullOrWhiteSpace(record.ConnectionID)
? record.ConnectionID
: null);
}
public Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync()
{
var now = DateTime.UtcNow;

View File

@ -16,6 +16,7 @@ public sealed class OnboardingWizardViewModel
public string? SelectedBookTitle { get; set; }
public bool IsMicrosoftWordPath => string.Equals(WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal);
public CompanionPresenceViewModel CompanionPresence { get; set; } = new();
public ManuscriptScanStateViewModel ScanState { get; set; } = new();
public IReadOnlyList<OnboardingOptionViewModel> Options { get; set; } = [];
public IReadOnlyList<OnboardingProjectOptionViewModel> ProjectOptions { get; set; } = [];
public IReadOnlyList<OnboardingBookOptionViewModel> BookOptions { get; set; } = [];
@ -106,8 +107,47 @@ public sealed class OnboardingNudgeViewModel
public sealed class CompanionPresenceViewModel
{
public bool IsConnected { get; init; }
public bool DocumentOpen { get; init; }
public string Status { get; init; } = "Offline";
public string? CompanionVersion { get; init; }
public string? CurrentDocumentName { get; init; }
public DateTime? LastHeartbeatUtc { get; init; }
}
public sealed class ManuscriptScanStateViewModel
{
public string Status { get; init; } = ManuscriptScanStatuses.NotStarted;
public string Message { get; init; } = string.Empty;
public int? PercentComplete { get; init; }
public int ChapterCount { get; init; }
public int SceneCount { get; init; }
public int CharacterCandidateCount { get; init; }
public int TotalWordCount { get; init; }
public Guid? PreviewID { get; init; }
public bool IsRunning => string.Equals(Status, ManuscriptScanStatuses.Running, StringComparison.Ordinal);
public bool IsComplete => string.Equals(Status, ManuscriptScanStatuses.Complete, StringComparison.Ordinal);
public bool IsFailed => string.Equals(Status, ManuscriptScanStatuses.Failed, StringComparison.Ordinal);
}
public sealed class ManuscriptScanReviewViewModel
{
public Guid PreviewID { get; init; }
public string DocumentTitle { get; init; } = "Word manuscript";
public string SelectedProjectName { get; init; } = string.Empty;
public string SelectedBookTitle { get; init; } = string.Empty;
public int TotalWordCount { get; init; }
public int ChapterCount { get; init; }
public int SceneCount { get; init; }
public int CharacterCandidateCount { get; init; }
public IReadOnlyList<ManuscriptScanReviewChapterViewModel> Chapters { get; init; } = [];
public IReadOnlyList<ManuscriptScanCharacterCandidatePreview> CharacterCandidates { get; init; } = [];
}
public sealed class ManuscriptScanReviewChapterViewModel
{
public string TemporaryChapterKey { get; init; } = string.Empty;
public int ChapterNumber { get; init; }
public string Title { get; init; } = string.Empty;
public int WordCount { get; init; }
public IReadOnlyList<ManuscriptScanScenePreview> Scenes { get; init; } = [];
}

View File

@ -258,9 +258,7 @@
<p class="eyebrow">Word Companion</p>
<h2 data-word-companion-waiting-status>@(Model.CompanionPresence.IsConnected ? "Word Companion Connected" : "Waiting for Word Companion...")</h2>
<p data-word-companion-connected-copy>
@(Model.CompanionPresence.IsConnected
? "Your Companion is connected. Continue when you are ready."
: "Once connected this page will update automatically. You stay in control before continuing.")
@CompanionScanMessage(Model.CompanionPresence, Model.ScanState)
</p>
<dl class="onboarding-companion-details">
<div>
@ -272,6 +270,50 @@
<dd data-word-companion-document>@(Model.CompanionPresence.CurrentDocumentName ?? "No document reported yet")</dd>
</div>
</dl>
<section class="onboarding-scan-panel"
data-onboarding-scan-panel
data-scan-status="@Model.ScanState.Status"
aria-label="Manuscript scan">
<div class="onboarding-scan-status">
<span data-onboarding-scan-message>@ScanMessage(Model.ScanState)</span>
<strong data-onboarding-scan-percent>@ScanPercent(Model.ScanState)</strong>
</div>
<div class="onboarding-scan-progress" aria-hidden="true">
<span data-onboarding-scan-progress style="width:@(Model.ScanState.PercentComplete.GetValueOrDefault())%"></span>
</div>
<div class="onboarding-scan-counts">
<div>
<span>Chapters</span>
<strong data-onboarding-scan-chapters>@Model.ScanState.ChapterCount</strong>
</div>
<div>
<span>Scenes</span>
<strong data-onboarding-scan-scenes>@Model.ScanState.SceneCount</strong>
</div>
<div>
<span>Characters</span>
<strong data-onboarding-scan-characters>@Model.ScanState.CharacterCandidateCount</strong>
</div>
<div>
<span>Words</span>
<strong data-onboarding-scan-words>@Model.ScanState.TotalWordCount.ToString("N0")</strong>
</div>
</div>
<div class="onboarding-scan-actions">
<button class="btn btn-primary"
type="button"
data-onboarding-scan-start
disabled="@(!CanStartScan(Model.CompanionPresence, Model.ScanState))">
@(Model.ScanState.IsComplete ? "Scan again" : "Scan manuscript")
</button>
<a class="btn btn-outline-primary @(Model.ScanState.IsComplete ? string.Empty : "disabled")"
data-onboarding-scan-review
href="@(Model.ScanState.PreviewID.HasValue ? Url.Action("ScanReview", "Onboarding", new { previewId = Model.ScanState.PreviewID }) : "#")"
aria-disabled="@(!Model.ScanState.IsComplete)">
Review scan
</a>
</div>
</section>
</div>
</section>
}
@ -332,4 +374,56 @@
WritingSoftwareValues.Other => "Other",
_ => "Not set"
};
private static bool CanStartScan(CompanionPresenceViewModel presence, ManuscriptScanStateViewModel scan)
=> presence.IsConnected && presence.DocumentOpen && !scan.IsRunning;
private static string CompanionScanMessage(CompanionPresenceViewModel presence, ManuscriptScanStateViewModel scan)
{
if (!presence.IsConnected)
{
return "Open Word and start the Companion. This page will update automatically.";
}
if (!presence.DocumentOpen)
{
return "Open your manuscript in Word, then the scan button will appear.";
}
if (scan.IsComplete)
{
return "Scan complete. Review the structure before anything is imported.";
}
if (scan.IsRunning)
{
return "The Companion is scanning your manuscript now.";
}
if (scan.IsFailed)
{
return "The scan needs another try. Your story data has not been changed.";
}
return "Your Companion is connected. Scan your manuscript when you are ready.";
}
private static string ScanMessage(ManuscriptScanStateViewModel scan)
{
if (!string.IsNullOrWhiteSpace(scan.Message))
{
return scan.Message;
}
return scan.Status switch
{
ManuscriptScanStatuses.Complete => "We found:",
ManuscriptScanStatuses.Running => "Preparing document scan",
ManuscriptScanStatuses.Failed => "The scan could not finish.",
_ => "Ready to scan"
};
}
private static string ScanPercent(ManuscriptScanStateViewModel scan)
=> scan.PercentComplete.HasValue ? $"{scan.PercentComplete.Value}%" : string.Empty;
}

View File

@ -0,0 +1,102 @@
@model ManuscriptScanReviewViewModel
@{
ViewData["Title"] = "Review manuscript scan";
}
<section class="onboarding-shell" aria-labelledby="scan-review-title">
<div class="onboarding-panel onboarding-review-panel">
<div class="onboarding-copy">
<p class="eyebrow">@Model.SelectedBookTitle</p>
<h1 id="scan-review-title">Review manuscript scan</h1>
<p>@Model.DocumentTitle</p>
</div>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Chapters</span>
<strong>@Model.ChapterCount</strong>
</div>
<div>
<span>Scenes</span>
<strong>@Model.SceneCount</strong>
</div>
<div>
<span>Characters</span>
<strong>@Model.CharacterCandidateCount</strong>
</div>
<div>
<span>Words</span>
<strong>@Model.TotalWordCount.ToString("N0")</strong>
</div>
</div>
<section class="onboarding-review-grid" aria-label="Scan preview">
<div class="onboarding-review-section">
<h2>Chapters and scenes</h2>
@if (!Model.Chapters.Any())
{
<p>No chapters were detected. Make sure your chapter headings use Heading 1, or add clear chapter headings.</p>
}
else
{
<div class="onboarding-review-chapters">
@foreach (var chapter in Model.Chapters)
{
<article class="onboarding-review-chapter">
<header>
<span>Chapter @chapter.ChapterNumber</span>
<strong>@chapter.Title</strong>
<small>@chapter.WordCount.ToString("N0") words / @chapter.Scenes.Count scene@(chapter.Scenes.Count == 1 ? string.Empty : "s")</small>
</header>
@if (chapter.Scenes.Any())
{
<ol>
@foreach (var scene in chapter.Scenes)
{
<li>
<strong>@(scene.Title ?? $"Scene {scene.SceneNumberWithinChapter}")</strong>
<span>@scene.WordCount.ToString("N0") words</span>
@if (!string.IsNullOrWhiteSpace(scene.OpeningTextPreview))
{
<p>@scene.OpeningTextPreview</p>
}
</li>
}
</ol>
}
else
{
<p>No scene breaks were detected. PlotDirector will treat this chapter as one scene for now.</p>
}
</article>
}
</div>
}
</div>
<aside class="onboarding-review-section">
<h2>Character candidates</h2>
@if (!Model.CharacterCandidates.Any())
{
<p>No repeated character names were found yet.</p>
}
else
{
<ul class="onboarding-character-candidates">
@foreach (var candidate in Model.CharacterCandidates)
{
<li>
<strong>@candidate.Name</strong>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s")</span>
</li>
}
</ul>
}
</aside>
</section>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a>
</div>
</div>
</section>

View File

@ -243,6 +243,156 @@
overflow-wrap: anywhere;
}
.onboarding-scan-panel {
display: grid;
gap: .85rem;
margin-top: 1rem;
border-top: 1px solid rgba(31, 42, 68, .1);
padding-top: 1rem;
}
.onboarding-scan-status {
display: flex;
justify-content: space-between;
gap: .75rem;
color: var(--bs-secondary-color);
font-weight: 700;
}
.onboarding-scan-progress {
height: .5rem;
overflow: hidden;
border-radius: 999px;
background: rgba(31, 42, 68, .12);
}
.onboarding-scan-progress span {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: #2f6f63;
transition: width .18s ease;
}
.onboarding-scan-counts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: .65rem;
}
.onboarding-scan-counts div {
min-width: 0;
border: 1px solid rgba(31, 42, 68, .1);
border-radius: 8px;
padding: .75rem;
background: rgba(255, 255, 255, .62);
}
.onboarding-scan-counts span {
display: block;
color: var(--bs-secondary-color);
font-size: .78rem;
font-weight: 800;
text-transform: uppercase;
}
.onboarding-scan-counts strong {
display: block;
margin-top: .15rem;
font-size: 1.2rem;
}
.onboarding-scan-actions {
display: flex;
flex-wrap: wrap;
gap: .65rem;
}
.onboarding-review-panel {
width: min(1120px, 100%);
}
.onboarding-review-counts {
margin-bottom: 1.25rem;
}
.onboarding-review-grid {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(280px, .8fr);
gap: 1rem;
}
.onboarding-review-section {
display: grid;
align-content: start;
gap: .8rem;
}
.onboarding-review-section h2 {
margin: 0;
font-size: 1.2rem;
}
.onboarding-review-chapters {
display: grid;
gap: .85rem;
}
.onboarding-review-chapter {
border: 1px solid rgba(31, 42, 68, .12);
border-radius: 8px;
padding: 1rem;
background: rgba(255, 255, 255, .58);
}
.onboarding-review-chapter header {
display: grid;
gap: .2rem;
margin-bottom: .75rem;
}
.onboarding-review-chapter header span,
.onboarding-review-chapter header small,
.onboarding-review-chapter li span {
color: var(--bs-secondary-color);
font-weight: 700;
}
.onboarding-review-chapter ol,
.onboarding-character-candidates {
display: grid;
gap: .65rem;
margin: 0;
padding: 0;
list-style: none;
}
.onboarding-review-chapter li,
.onboarding-character-candidates li {
border: 1px solid rgba(31, 42, 68, .1);
border-radius: 8px;
padding: .75rem;
background: rgba(255, 255, 255, .64);
}
.onboarding-review-chapter li {
display: grid;
gap: .2rem;
}
.onboarding-review-chapter li p {
margin: .2rem 0 0;
color: var(--bs-secondary-color);
}
.onboarding-character-candidates li {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
}
.onboarding-summary div {
display: flex;
align-items: center;
@ -333,6 +483,7 @@
.onboarding-dashboard-nudge,
.onboarding-companion-card,
.onboarding-review-grid,
.onboarding-summary div {
align-items: stretch;
grid-template-columns: 1fr;
@ -342,4 +493,8 @@
.onboarding-companion-details {
grid-template-columns: 1fr;
}
.onboarding-scan-counts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@ -1251,6 +1251,9 @@
.build();
companionPresenceConnection.onreconnected(refreshCompanionPresence);
companionPresenceConnection.on("ScanCurrentDocument", (command) => {
void runOnboardingDocumentScan(command);
});
companionPresenceConnection.onclose(() => {
if (companionPresenceHeartbeatTimer) {
window.clearInterval(companionPresenceHeartbeatTimer);
@ -1890,6 +1893,247 @@
};
};
const properNameStopWords = new Set([
"Chapter", "Scene", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday",
"January", "February", "March", "April", "May", "June", "July", "August", "September", "October",
"November", "December", "The", "A", "An", "And", "But", "Or", "If", "Then", "When", "Where",
"This", "That", "These", "Those", "He", "She", "They", "We", "I", "You", "It", "His", "Her",
"Their", "Our", "Your", "My", "Word", "PlotDirector"
]);
const candidateCharacterNames = (text) => {
const counts = new Map();
const matches = String(text || "").match(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2}\b/g) || [];
for (const match of matches) {
const name = match.trim();
const parts = name.split(/\s+/);
if (parts.some((part) => properNameStopWords.has(part)) || name.length < 3) {
continue;
}
counts.set(name, (counts.get(name) || 0) + 1);
}
return [...counts.entries()]
.filter(([, count]) => count >= 2)
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.slice(0, 40)
.map(([name, count], index) => ({
temporaryCharacterKey: `character-${index + 1}`,
name,
mentionCount: count,
suggestedImportance: null
}));
};
const buildOnboardingScanPreview = (paragraphs, command) => {
const chapters = [];
const scenes = [];
const documentText = [];
let chapter = null;
let scene = null;
let totalWordCount = 0;
let detectedHeadingChapters = 0;
const finishScene = () => {
scene = null;
};
const startChapter = (paragraph, title, isOpening = false) => {
finishScene();
chapter = {
temporaryChapterKey: `chapter-${chapters.length + 1}`,
chapterNumber: chapters.length + 1,
title,
wordCount: 0,
startPosition: paragraph?.index ?? null,
existingChapterID: isOpening ? null : paragraphAnchorId(paragraph, "PD-CHAPTER")
};
chapters.push(chapter);
startScene(paragraph, null);
return chapter;
};
const startScene = (paragraph, title = null) => {
if (!chapter) {
return null;
}
finishScene();
scene = {
temporarySceneKey: `scene-${scenes.length + 1}`,
temporaryChapterKey: chapter.temporaryChapterKey,
sceneNumberWithinChapter: scenes.filter((item) => item.temporaryChapterKey === chapter.temporaryChapterKey).length + 1,
title,
wordCount: 0,
openingTextPreview: "",
startPosition: paragraph?.index ?? null,
existingSceneID: paragraph ? paragraphAnchorId(paragraph, "PD-SCENE") : null
};
scenes.push(scene);
return scene;
};
const addWords = (text, words) => {
if (!chapter || !scene || words <= 0) {
return;
}
chapter.wordCount += words;
scene.wordCount += words;
if (!scene.openingTextPreview && text && !sceneSeparatorTexts.has(text)) {
scene.openingTextPreview = text.length > 180 ? `${text.slice(0, 177)}...` : text;
}
};
paragraphs.forEach((paragraph, index) => {
paragraph.index = index;
const text = String(paragraph.text || "").trim();
if (!text) {
return;
}
documentText.push(text);
const words = countWords(text);
if (isBuiltInHeading(paragraph, 1)) {
detectedHeadingChapters += 1;
startChapter(paragraph, text);
totalWordCount += words;
chapter.wordCount += words;
return;
}
if (!chapter) {
startChapter(paragraph, "Opening pages", true);
}
if (isBuiltInHeading(paragraph, 2)) {
startScene(paragraph, text);
totalWordCount += words;
addWords(text, words);
return;
}
if (sceneSeparatorTexts.has(text)) {
startScene(paragraph, null);
return;
}
totalWordCount += words;
addWords(text, words);
});
if (totalWordCount === 0) {
throw new Error("The document appears to be empty. Add manuscript text, then try again.");
}
if (detectedHeadingChapters === 0) {
throw new Error("No chapters were detected. Make sure your chapter headings use Heading 1, or add clear chapter headings.");
}
const characterCandidates = candidateCharacterNames(documentText.join("\n"));
return {
previewID: "00000000-0000-0000-0000-000000000000",
userID: command.userID || command.UserID || 0,
onboardingID: command.onboardingID || command.OnboardingID || 0,
projectID: command.projectID || command.ProjectID || 0,
bookID: command.bookID || command.BookID || 0,
source: "WordCompanion",
documentTitle: currentDocumentName() || "Word manuscript",
companionDocumentIdentifier: currentDocumentGuid(),
totalWordCount,
chapterCount: chapters.length,
sceneCount: scenes.length,
characterCandidateCount: characterCandidates.length,
createdUtc: new Date().toISOString(),
chapters,
scenes,
characterCandidates
};
};
const scanCommandValue = (command, camel, pascal) => command?.[camel] ?? command?.[pascal] ?? null;
const reportOnboardingScanProgress = async (command, message, percentComplete, counts = {}) => {
if (!companionPresenceConnection || companionPresenceConnection.state !== signalR.HubConnectionState.Connected) {
return;
}
await companionPresenceConnection.invoke("ReportOnboardingScanProgress", {
userID: scanCommandValue(command, "userID", "UserID"),
onboardingID: scanCommandValue(command, "onboardingID", "OnboardingID"),
message,
percentComplete,
chapterCount: counts.chapterCount || 0,
sceneCount: counts.sceneCount || 0,
characterCandidateCount: counts.characterCandidateCount || 0,
totalWordCount: counts.totalWordCount || 0
});
};
const failOnboardingScan = async (command, message) => {
if (!companionPresenceConnection || companionPresenceConnection.state !== signalR.HubConnectionState.Connected) {
return;
}
await companionPresenceConnection.invoke("FailOnboardingScan", {
userID: scanCommandValue(command, "userID", "UserID"),
onboardingID: scanCommandValue(command, "onboardingID", "OnboardingID"),
message
});
};
const runOnboardingDocumentScan = async (command) => {
if (!wordHostAvailable || !window.Word) {
await failOnboardingScan(command, "Open your manuscript in Microsoft Word, then try the scan again.");
return;
}
try {
await reportOnboardingScanProgress(command, "Preparing document scan", 5);
const paragraphs = await window.Word.run(async (context) => {
const bodyParagraphs = context.document.body.paragraphs;
bodyParagraphs.load("items/text,items/style,items/styleBuiltIn");
await context.sync();
await reportOnboardingScanProgress(command, "Detecting chapters", 20);
for (const paragraph of bodyParagraphs.items) {
if (isBuiltInHeading(paragraph, 1) || isBuiltInHeading(paragraph, 2)) {
paragraph.contentControls.load("items/tag,title");
}
}
await context.sync();
return bodyParagraphs.items;
});
const preview = buildOnboardingScanPreview(paragraphs, command);
await reportOnboardingScanProgress(command, "Detecting scene breaks", 50, preview);
await reportOnboardingScanProgress(command, `Scene ${preview.sceneCount} of ${preview.sceneCount} scanned`, 70, preview);
await reportOnboardingScanProgress(command, "Finding character candidates", 85, preview);
await reportOnboardingScanProgress(command, "Preparing preview", 95, preview);
await companionPresenceConnection.invoke("CompleteOnboardingScan", preview);
} catch (error) {
console.error("Unable to complete onboarding scan.", error);
await failOnboardingScan(command, friendlyScanError(error));
}
};
const friendlyScanError = (error) => {
const message = errorText(error);
if (/No chapters were detected/i.test(message)) {
return "No chapters were detected. Make sure your chapter headings use Heading 1, or add clear chapter headings.";
}
if (/empty/i.test(message)) {
return "The document appears to be empty. Add manuscript text, then try again.";
}
if (/network|disconnect|connection/i.test(message)) {
return "The Word Companion disconnected before the scan finished. Reopen Word and try again.";
}
return `The scan could not finish: ${message}`;
};
const paragraphMatchesSnapshot = (snapshot, paragraph) => {
return String(snapshot?.text || "").trim() === String(paragraph?.text || "").trim()
&& normalizedStyleName(snapshot) === normalizedStyleName(paragraph);

File diff suppressed because one or more lines are too long

View File

@ -7,6 +7,9 @@
const labelFor = (status) => (status?.isConnected || status?.IsConnected ? "Connected" : status?.status || status?.Status || "Offline");
const field = (status, camel, pascal) => status?.[camel] ?? status?.[pascal] ?? null;
let companionConnected = false;
let companionDocumentOpen = false;
let currentScanStatus = document.querySelector("[data-onboarding-scan-panel]")?.dataset.scanStatus || "NotStarted";
const formatTime = (value) => {
if (!value) {
@ -19,9 +22,12 @@
const applyStatus = (status) => {
const connected = !!field(status, "isConnected", "IsConnected");
const documentOpen = !!field(status, "documentOpen", "DocumentOpen");
const version = field(status, "companionVersion", "CompanionVersion") || "Waiting";
const documentName = field(status, "currentDocumentName", "CurrentDocumentName") || "No document reported yet";
const label = connected ? "Connected" : labelFor(status);
companionConnected = connected;
companionDocumentOpen = documentOpen;
const title = [
`Word Companion: ${label}`,
version && version !== "Waiting" ? `Version ${version}` : "",
@ -43,9 +49,17 @@
node.textContent = connected ? "Word Companion Connected" : "Word Companion disconnected. Waiting for reconnection...";
});
document.querySelectorAll("[data-word-companion-connected-copy]").forEach((node) => {
node.textContent = connected
? "Your Companion is connected. Continue when you are ready."
: "Open Word and start the Companion. This page will update automatically.";
if (!connected) {
node.textContent = "Open Word and start the Companion. This page will update automatically.";
} else if (!documentOpen) {
node.textContent = "Open your manuscript in Word, then the scan button will appear.";
} else if (currentScanStatus === "Complete") {
node.textContent = "Scan complete. Review the structure before anything is imported.";
} else if (currentScanStatus === "Running") {
node.textContent = "The Companion is scanning your manuscript now.";
} else {
node.textContent = "Your Companion is connected. Scan your manuscript when you are ready.";
}
});
document.querySelectorAll("[data-word-companion-version]").forEach((node) => {
node.textContent = version;
@ -61,6 +75,67 @@
? "Your Word Companion is ready for this story setup."
: "Open Word, start the Companion, and sign in with your PlotDirector account.";
});
updateScanButton();
};
const numberText = (value) => {
const number = Number.parseInt(value || "0", 10);
return Number.isInteger(number) ? number.toLocaleString() : "0";
};
const updateScanButton = () => {
document.querySelectorAll("[data-onboarding-scan-start]").forEach((button) => {
button.disabled = !companionConnected || !companionDocumentOpen || currentScanStatus === "Running";
button.textContent = currentScanStatus === "Complete" ? "Scan again" : "Scan manuscript";
});
};
const applyScanState = (state) => {
if (!state) {
return;
}
const status = field(state, "status", "Status") || "NotStarted";
currentScanStatus = status;
const message = field(state, "message", "Message") || (status === "Complete" ? "We found:" : "Ready to scan");
const percent = field(state, "percentComplete", "PercentComplete");
const previewId = field(state, "previewID", "PreviewID") || field(state, "previewId", "PreviewId");
const panel = document.querySelector("[data-onboarding-scan-panel]");
if (panel) {
panel.dataset.scanStatus = status;
panel.classList.toggle("is-running", status === "Running");
panel.classList.toggle("is-complete", status === "Complete");
panel.classList.toggle("is-failed", status === "Failed");
}
document.querySelectorAll("[data-onboarding-scan-message]").forEach((node) => {
node.textContent = message;
});
document.querySelectorAll("[data-onboarding-scan-percent]").forEach((node) => {
node.textContent = percent === null || percent === undefined ? "" : `${percent}%`;
});
document.querySelectorAll("[data-onboarding-scan-progress]").forEach((node) => {
node.style.width = `${Math.max(0, Math.min(100, Number.parseInt(percent || "0", 10) || 0))}%`;
});
document.querySelectorAll("[data-onboarding-scan-chapters]").forEach((node) => {
node.textContent = numberText(field(state, "chapterCount", "ChapterCount"));
});
document.querySelectorAll("[data-onboarding-scan-scenes]").forEach((node) => {
node.textContent = numberText(field(state, "sceneCount", "SceneCount"));
});
document.querySelectorAll("[data-onboarding-scan-characters]").forEach((node) => {
node.textContent = numberText(field(state, "characterCandidateCount", "CharacterCandidateCount"));
});
document.querySelectorAll("[data-onboarding-scan-words]").forEach((node) => {
node.textContent = numberText(field(state, "totalWordCount", "TotalWordCount"));
});
document.querySelectorAll("[data-onboarding-scan-review]").forEach((node) => {
const ready = status === "Complete" && previewId;
node.classList.toggle("disabled", !ready);
node.setAttribute("aria-disabled", String(!ready));
node.href = ready ? `/onboarding/scan-review?previewId=${encodeURIComponent(previewId)}` : "#";
});
updateScanButton();
};
const connection = new signalR.HubConnectionBuilder()
@ -69,6 +144,10 @@
.build();
connection.on("WordCompanionPresenceChanged", applyStatus);
connection.on("OnboardingScanStateChanged", applyScanState);
connection.on("OnboardingScanProgress", applyScanState);
connection.on("OnboardingScanCompleted", applyScanState);
connection.on("OnboardingScanFailed", applyScanState);
connection.onreconnecting(() => applyStatus({ status: "Connecting", isConnected: false }));
connection.onreconnected(() => connection.invoke("WatchCompanionPresence").then(applyStatus).catch(() => {}));
connection.onclose(() => applyStatus({ status: "Offline", isConnected: false }));
@ -77,4 +156,20 @@
.then(() => connection.invoke("WatchCompanionPresence"))
.then(applyStatus)
.catch(() => applyStatus({ status: "Offline", isConnected: false }));
document.querySelectorAll("[data-onboarding-scan-start]").forEach((button) => {
button.addEventListener("click", async () => {
button.disabled = true;
applyScanState({ status: "Running", message: "Asking the Word Companion to scan...", percentComplete: 0 });
try {
const state = await connection.invoke("StartOnboardingManuscriptScan");
applyScanState(state);
} catch (error) {
applyScanState({
status: "Failed",
message: error?.message || "The scan could not start. Reconnect the Word Companion and try again."
});
}
});
});
})();