using System.Collections.Concurrent; using PlotLine.Models; namespace PlotLine.Services; public interface IManuscriptScanPreviewStore { Task StartAsync(int userId, int onboardingId, int projectId, int bookId); Task ClearAsync(int userId, int onboardingId); 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); Task GetReviewAsync(int userId, Guid previewId); Task SaveReviewAsync(int userId, ManuscriptScanReviewDecision review); Task BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null); Task GetBuildResultAsync(int userId, Guid previewId); Task SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result); } public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore { private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new(); private readonly ConcurrentDictionary previews = new(); private readonly ConcurrentDictionary reviews = new(); private readonly ConcurrentDictionary buildResults = new(); private readonly ConcurrentDictionary buildProgress = 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 ClearAsync(int userId, int onboardingId) { sessions.TryRemove((userId, onboardingId), out _); return Task.CompletedTask; } 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.ReviewStatus = ManuscriptScanReviewStatuses.ScanComplete; 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); public Task GetReviewAsync(int userId, Guid previewId) { if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId) { return Task.FromResult(null); } var review = reviews.GetOrAdd(previewId, _ => BuildDefaultReview(preview)); return Task.FromResult(review); } public Task SaveReviewAsync(int userId, ManuscriptScanReviewDecision review) { if (!previews.TryGetValue(review.PreviewID, out var preview) || preview.UserID != userId) { return Task.FromResult(null); } var stored = new ManuscriptScanReviewDecision { PreviewID = review.PreviewID, Status = review.Status, UpdatedUtc = DateTime.UtcNow, Chapters = review.Chapters, Scenes = review.Scenes, Characters = review.Characters }; reviews[review.PreviewID] = stored; if (sessions.TryGetValue((userId, preview.OnboardingID), out var session)) { session.ReviewStatus = stored.Status; session.UpdatedUtc = stored.UpdatedUtc; } return Task.FromResult(stored); } public Task BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null) { if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId) { return Task.FromResult(new OnboardingBuildProgress { Status = "Failed", Message = "The scan preview could not be found." }); } var progress = new OnboardingBuildProgress { Status = result is null ? "Running" : "Complete", Message = message, PercentComplete = percentComplete, ChaptersCreated = result?.ChaptersCreated ?? 0, ScenesCreated = result?.ScenesCreated ?? 0, CharactersCreated = (result?.CharactersCreated ?? 0) + (result?.CharactersReused ?? 0), SceneAppearancesCreated = result?.SceneAppearancesCreated ?? 0, PreviewID = previewId }; buildProgress[previewId] = progress; return Task.FromResult(progress); } public Task GetBuildResultAsync(int userId, Guid previewId) => Task.FromResult(previews.TryGetValue(previewId, out var preview) && preview.UserID == userId && buildResults.TryGetValue(previewId, out var result) ? result : null); public Task SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result) { if (!previews.TryGetValue(result.PreviewID, out var preview) || preview.UserID != userId) { return Task.FromResult(null); } buildResults[result.PreviewID] = result; return Task.FromResult(result); } private static ManuscriptScanState ToState(ScanSession session) => new() { Status = session.Status, ReviewStatus = session.ReviewStatus, 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 static ManuscriptScanReviewDecision BuildDefaultReview(ManuscriptScanPreview preview) => new() { PreviewID = preview.PreviewID, Status = ManuscriptScanReviewStatuses.ScanComplete, Chapters = preview.Chapters .OrderBy(chapter => chapter.ChapterNumber) .Select(chapter => new ManuscriptScanChapterReviewDecision { TemporaryChapterKey = chapter.TemporaryChapterKey, Include = !string.IsNullOrWhiteSpace(chapter.Title) && chapter.WordCount > 0, Title = chapter.Title, ChapterNumber = chapter.ChapterNumber }) .ToList(), Scenes = preview.Scenes .OrderBy(scene => scene.TemporaryChapterKey) .ThenBy(scene => scene.SceneNumberWithinChapter) .Select(scene => new ManuscriptScanSceneReviewDecision { TemporarySceneKey = scene.TemporarySceneKey, TemporaryChapterKey = scene.TemporaryChapterKey, Include = true, Title = string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title, SceneNumberWithinChapter = scene.SceneNumberWithinChapter }) .ToList(), Characters = preview.CharacterCandidates .Select(candidate => new ManuscriptScanCharacterReviewDecision { TemporaryCharacterKey = candidate.TemporaryCharacterKey, Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase), Name = candidate.Name, Category = candidate.Category }) .ToList() }; 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 ReviewStatus { get; set; } = ManuscriptScanReviewStatuses.ScanComplete; 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 }; }