Phase 20D Implement website-led manuscript scanning via the Word Companion.
This commit is contained in:
parent
6f41605f08
commit
a70527f3a4
@ -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()
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
105
PlotLine/Models/ManuscriptScanModels.cs
Normal file
105
PlotLine/Models/ManuscriptScanModels.cs
Normal 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; }
|
||||
}
|
||||
@ -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>();
|
||||
|
||||
195
PlotLine/Services/ManuscriptScanPreviewStore.cs
Normal file
195
PlotLine/Services/ManuscriptScanPreviewStore.cs
Normal 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
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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; } = [];
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
102
PlotLine/Views/Onboarding/ScanReview.cshtml
Normal file
102
PlotLine/Views/Onboarding/ScanReview.cshtml
Normal 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>
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -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."
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user