using System.Collections.Concurrent; using PlotLine.Models; namespace PlotLine.Services; public interface IManuscriptScanPreviewStore { Task StartAsync(int userId, int onboardingId, int projectId, int bookId); Task ProgressAsync(int userId, ManuscriptScanProgress progress); Task CompleteAsync(int userId, ManuscriptScanPreview preview); Task FailAsync(int userId, int onboardingId, string message); Task> FailRunningForUserAsync(int userId, string message); Task GetStateAsync(int userId, int onboardingId); Task GetLatestPreviewAsync(int userId, int onboardingId); Task GetPreviewAsync(int userId, Guid previewId); } public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore { private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new(); private readonly ConcurrentDictionary previews = new(); public Task 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 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 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 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> FailRunningForUserAsync(int userId, string message) { var failed = new List(); 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>(failed); } public Task GetStateAsync(int userId, int onboardingId) => Task.FromResult(sessions.TryGetValue((userId, onboardingId), out var session) ? ToState(session) : new ManuscriptScanState()); public Task GetLatestPreviewAsync(int userId, int onboardingId) { if (!sessions.TryGetValue((userId, onboardingId), out var session) || !session.PreviewID.HasValue) { return Task.FromResult(null); } return GetPreviewAsync(userId, session.PreviewID.Value); } public Task 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 }; }