using System.Text.Json; using System.Text.RegularExpressions; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IOnboardingService { Task GetOrCreateAsync(); Task GetWizardAsync(); Task GetScanReviewAsync(Guid? previewId = null); Task SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport); Task GetBasicLocationReviewAsync(Guid previewId); Task SaveBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form); Task CompleteBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form); Task SkipBasicLocationReviewAsync(Guid previewId); Task IsBasicLocationReviewCompleteAsync(Guid previewId); Task EnsureCharacterCandidatesAfterLocationReviewAsync(Guid previewId); Task FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form); Task BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func? progress = null); Task GetBuildResultAsync(Guid previewId); Task ShouldShowOnboardingAsync(); Task GetDashboardNudgeAsync(); Task ShouldShowWordCompanionHeaderAsync(); Task MoveToNextStepAsync(); Task StartManualSetupAsync(); Task StartManuscriptImportSetupAsync(); Task BeginManuscriptImportForBookAsync(int projectId, int bookId); Task SetCurrentStepAsync(string currentStep); Task SaveWritingJourneyAsync(string writingJourney); Task SaveWritingSoftwareAsync(string writingSoftware); Task SaveProjectAsync(ProjectOnboardingForm form); Task SaveBookAsync(BookOnboardingForm form); Task MarkCompletedAsync(); } public sealed class OnboardingService( IOnboardingRepository onboarding, IProjectRepository projects, IBookRepository books, IBookService bookService, IProjectActivityService activity, IWordCompanionPresenceService companionPresence, IManuscriptScanPreviewStore scanPreviews, ICharacterRepository characters, IChapterRepository chapters, ISceneRepository scenes, IOnboardingBuildRepository builds, IManuscriptStructureAnalysisService structureAnalysis, IManuscriptTextStatisticsService manuscriptTextStatistics, ICoreImportCharacterMappingService coreImportCharacterMapping, ICoreImportBasicLocationService coreImportBasicLocations, ICurrentUserService currentUser) : IOnboardingService { private static readonly HashSet WritingJourneys = new(StringComparer.Ordinal) { WritingJourneyValues.PlanningNewStory, WritingJourneyValues.AlreadyStarted, WritingJourneyValues.ManuscriptMostlyComplete }; private static readonly HashSet WritingSoftware = new(StringComparer.Ordinal) { WritingSoftwareValues.MicrosoftWord, WritingSoftwareValues.GoogleDocs, WritingSoftwareValues.Scrivener, WritingSoftwareValues.LibreOfficeOpenOffice, WritingSoftwareValues.MarkdownObsidian, WritingSoftwareValues.Other }; public async Task GetOrCreateAsync() => await onboarding.GetOrCreateAsync(RequireUserId()); public async Task GetWizardAsync() { var state = await GetOrCreateAsync(); var step = ResolveStep(state); var projectOptions = await BuildProjectOptionsAsync(); var bookOptions = state.ProjectID.HasValue ? await BuildBookOptionsAsync(state.ProjectID.Value) : []; var selectedProject = state.ProjectID.HasValue ? await projects.GetForUserAsync(state.ProjectID.Value, RequireUserId()) : null; var selectedBook = state.BookID.HasValue ? await books.GetAsync(state.BookID.Value) : null; var presence = await companionPresence.GetStatusAsync(RequireUserId()); var scanState = await scanPreviews.GetStateAsync(RequireUserId(), state.UserOnboardingStateID); return new OnboardingWizardViewModel { CurrentStep = step, StepNumber = StepNumber(step), TotalSteps = 11, WritingJourney = state.WritingJourney, WritingSoftware = state.WritingSoftware, ProjectID = state.ProjectID, BookID = state.BookID, SelectedProjectName = selectedProject?.ProjectName, SelectedBookTitle = selectedBook is null ? null : BookOptionTitle(selectedBook), CompanionPresence = ToPresenceViewModel(presence), ScanState = ToScanStateViewModel(scanState), ProjectOptions = projectOptions, BookOptions = bookOptions, ProjectForm = new ProjectOnboardingForm { Mode = projectOptions.Count == 0 || !state.ProjectID.HasValue ? OnboardingSelectionModes.Create : OnboardingSelectionModes.Select, SelectedProjectID = state.ProjectID }, BookForm = new BookOnboardingForm { Mode = bookOptions.Count == 0 || !state.BookID.HasValue ? OnboardingSelectionModes.Create : OnboardingSelectionModes.Select, SelectedBookID = state.BookID, StoryEra = selectedBook?.StoryEra, PrimaryLocaleCode = selectedBook?.PrimaryLocaleCode }, Options = step switch { OnboardingSteps.WritingJourney => WritingJourneyOptions(), OnboardingSteps.WritingSoftware => WritingSoftwareOptions(), _ => [] } }; } public async Task GetScanReviewAsync(Guid? previewId = null) { var context = await GetScanReviewContextAsync(previewId); return context is null ? null : BuildScanReviewViewModel(context.Value); } public async Task SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport) { var context = await GetScanReviewContextAsync(form.PreviewID); if (context is null) { return null; } var (userId, preview, _, project, book, existingCharacters, _) = context.Value; var chapterLookup = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal); var sceneLookup = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal); var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal); var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet(); var chapterDecisions = form.Chapters .Where(item => chapterLookup.ContainsKey(item.TemporaryChapterKey)) .Select(item => new ManuscriptScanChapterReviewDecision { TemporaryChapterKey = item.TemporaryChapterKey, Include = item.Include, Title = Clean(item.Title), ChapterNumber = item.ChapterNumber }) .OrderBy(item => item.ChapterNumber) .ToList(); var includedChapterKeys = chapterDecisions.Where(item => item.Include).Select(item => item.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal); var sceneDecisions = form.Scenes .Where(item => sceneLookup.ContainsKey(item.TemporarySceneKey)) .Select(item => new ManuscriptScanSceneReviewDecision { TemporarySceneKey = item.TemporarySceneKey, TemporaryChapterKey = item.TemporaryChapterKey, Include = item.Include && includedChapterKeys.Contains(item.TemporaryChapterKey), Title = CleanOptional(item.Title), SceneNumberWithinChapter = item.SceneNumberWithinChapter, AllowZeroWords = item.AllowZeroWords }) .OrderBy(item => item.TemporaryChapterKey) .ThenBy(item => item.SceneNumberWithinChapter) .ToList(); var characterDecisions = form.Characters .Where(item => candidateLookup.ContainsKey(item.TemporaryCharacterKey)) .Select(item => { var cleanName = Clean(item.Name); var aliasTargetKey = CleanOptional(item.AliasTargetTemporaryCharacterKey); if (!string.IsNullOrWhiteSpace(aliasTargetKey) && (!candidateLookup.ContainsKey(aliasTargetKey) || string.Equals(aliasTargetKey, item.TemporaryCharacterKey, StringComparison.Ordinal))) { aliasTargetKey = null; } var matchedExistingId = string.IsNullOrWhiteSpace(aliasTargetKey) && item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value) ? item.ExistingCharacterID : string.IsNullOrWhiteSpace(aliasTargetKey) ? MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID : null; return new ManuscriptScanCharacterReviewDecision { TemporaryCharacterKey = item.TemporaryCharacterKey, Include = item.Include, Name = cleanName, Category = item.Category, ExistingCharacterID = matchedExistingId, AliasTargetTemporaryCharacterKey = aliasTargetKey, SexHint = CharacterSexHintService.Normalise(item.SexHint, cleanName) }; }) .ToList(); var review = new ManuscriptScanReviewDecision { PreviewID = preview.PreviewID, Status = ManuscriptScanReviewStatuses.ReviewInProgress, GenerateSceneTitles = form.GenerateSceneTitles, Chapters = chapterDecisions, Scenes = sceneDecisions, Characters = characterDecisions }; if (readyToImport) { await scanPreviews.SaveReviewAsync(userId, review); ValidateScanStructure(review, preview); review = new ManuscriptScanReviewDecision { PreviewID = review.PreviewID, Status = ManuscriptScanReviewStatuses.ReadyToImport, GenerateSceneTitles = review.GenerateSceneTitles, Chapters = review.Chapters, Scenes = review.Scenes, Characters = review.Characters }; } else { ValidateScanStructure(review, preview); } var saved = await scanPreviews.SaveReviewAsync(userId, review); var build = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID) ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters, build)); } public async Task GetBasicLocationReviewAsync(Guid previewId) { var context = await GetScanReviewContextAsync(previewId); if (context is null) { return null; } var (userId, preview, _, project, book, _, contextBuild) = context.Value; var build = contextBuild ?? await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID) ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); if (build is null || build.SceneMappings.Count == 0) { return null; } var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); await coreImportBasicLocations.EnsurePreparedAsync(userId, preview.PreviewID, preview.ProjectID, preview.BookID, build.BuildID, sceneSources); return await coreImportBasicLocations.GetReviewAsync(userId, preview.PreviewID, preview.DocumentTitle ?? "Word manuscript", project.ProjectName, BookOptionTitle(book)); } public async Task SaveBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form) { await coreImportBasicLocations.SaveDecisionsAsync(RequireUserId(), form); } public async Task CompleteBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form) { var userId = RequireUserId(); await coreImportBasicLocations.SaveDecisionsAsync(userId, form); await coreImportBasicLocations.CompleteAsync(userId, form.PreviewID); await EnsureCharacterCandidatesAfterLocationReviewAsync(form.PreviewID); } public async Task SkipBasicLocationReviewAsync(Guid previewId) { var userId = RequireUserId(); await coreImportBasicLocations.SkipAsync(userId, previewId); await EnsureCharacterCandidatesAfterLocationReviewAsync(previewId); } public async Task IsBasicLocationReviewCompleteAsync(Guid previewId) => await coreImportBasicLocations.IsCompleteAsync(RequireUserId(), previewId); public async Task EnsureCharacterCandidatesAfterLocationReviewAsync(Guid previewId) { var context = await GetScanReviewContextAsync(previewId) ?? throw new InvalidOperationException("The scan review could not be opened. Please scan the manuscript again."); var (userId, preview, _, _, _, _, contextBuild) = context; var build = contextBuild ?? await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID) ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); if (build is null || build.SceneMappings.Count == 0) { throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters."); } if (!await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID)) { throw new InvalidOperationException("Review or skip basic locations before reviewing characters."); } var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); await PrepareCharacterCandidatesAfterLocationReviewAsync(userId, preview, sceneSources); } public async Task BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func? progress = null) { var context = await GetScanReviewContextAsync(previewId); if (context is null) { return null; } var (userId, preview, review, _, book, _, _) = context.Value; if (!string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal)) { throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters."); } if (review.GenerateSceneTitles != generateSceneTitles) { review = new ManuscriptScanReviewDecision { PreviewID = review.PreviewID, Status = review.Status, GenerateSceneTitles = generateSceneTitles, Chapters = review.Chapters, Scenes = review.Scenes, Characters = review.Characters }; await scanPreviews.SaveReviewAsync(userId, review); } var existingBuild = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID) ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); if (existingBuild is not null && existingBuild.SceneMappings.Count > 0) { var existingSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); var sourceSceneIds = existingSceneSources.Select(source => source.SceneID).ToHashSet(); var missingSceneSources = existingBuild.SceneMappings .Where(mapping => !sourceSceneIds.Contains(mapping.SceneID)) .ToList(); var repairedSceneSources = missingSceneSources.Count > 0; if (repairedSceneSources) { await PublishBuildProgress(userId, preview.PreviewID, "Completing saved scene source text before updating Word markers...", 65, progress, existingBuild); var recoveredScenes = await AnalyseApprovedChaptersAsync(userId, preview, review, book, progress); await SaveStructuralChapterSourcesAsync(preview, existingBuild, recoveredScenes); await SaveSceneManuscriptSourcesAsync(preview, recoveredScenes, existingBuild); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); await ApplyStructuralPovAsync(preview, recoveredScenes, existingBuild); } else { await manuscriptTextStatistics.RecalculateBookAsync(preview.BookID); } var resumed = await BuildExistingStructureResultAsync(userId, preview, review, existingBuild); await scanPreviews.SaveBuildResultAsync(userId, resumed); await PublishBuildProgress( userId, preview.PreviewID, repairedSceneSources ? "Scene source text recovered. Updating Word markers." : await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID) ? "Resuming Word marker sync. No scene detection was rerun." : "Basic location review is ready. Review locations before character candidates.", 90, progress, resumed); return resumed; } await PublishBuildProgress(userId, preview.PreviewID, "Preparing approved chapters for scene detection...", 10, progress); ValidateScanStructure(review, preview); var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList(); await PublishBuildProgress(userId, preview.PreviewID, "Detecting scene boundaries...", 20, progress); var structuralScenes = await AnalyseApprovedChaptersAsync(userId, preview, review, book, progress); await PublishBuildProgress(userId, preview.PreviewID, "Validating detected scene structure...", 45, progress); var request = new OnboardingManuscriptBuildRequest { PreviewID = preview.PreviewID, ProjectID = preview.ProjectID, BookID = preview.BookID, DocumentGuid = Guid.TryParse(preview.CompanionDocumentIdentifier, out var documentGuid) ? documentGuid : Guid.NewGuid(), Chapters = chapterDecisions.Select((chapter, index) => new OnboardingBuildChapterRequest { TemporaryChapterKey = chapter.TemporaryChapterKey, Title = chapter.Title, SortOrder = (index + 1) * 10, WordCount = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapter.TemporaryChapterKey)?.WordCount ?? 0 }).ToList(), Scenes = structuralScenes.Select(scene => new OnboardingBuildSceneRequest { TemporarySceneKey = scene.TemporarySceneKey, TemporaryChapterKey = scene.TemporaryChapterKey, Title = generateSceneTitles ? scene.SuggestedTitle ?? string.Empty : string.Empty, SuggestedTitle = generateSceneTitles ? scene.SuggestedTitle : null, SortOrder = scene.SceneNumberWithinChapter * 10, WordCount = scene.WordCount }).ToList(), Characters = [] }; await PublishBuildProgress(userId, preview.PreviewID, "Creating PlotDirector scenes...", 60, progress); var result = await builds.BuildAsync(userId, request) ?? throw new InvalidOperationException($"The project chapters could not be prepared. Approved chapters: {request.Chapters.Count}. Return to review, save your selections, and try again."); result = new OnboardingManuscriptBuildResult { BuildID = result.BuildID, PreviewID = result.PreviewID, ProjectID = result.ProjectID, BookID = result.BookID, ManuscriptDocumentID = result.ManuscriptDocumentID, Status = result.Status, MarkerStatus = result.MarkerStatus, ChaptersCreated = result.ChaptersCreated, ScenesCreated = result.ScenesCreated, CharactersCreated = result.CharactersCreated, CharactersReused = result.CharactersReused, SceneAppearancesCreated = result.SceneAppearancesCreated, AlreadyBuilt = result.AlreadyBuilt, Message = "Manuscript structure created. Updating and saving Word markers...", ChapterMappings = result.ChapterMappings, SceneMappings = result.SceneMappings, CharacterMappings = result.CharacterMappings, ChapterMarkerInstructions = BuildChapterMarkerInstructions(preview, result.ChapterMappings), SceneMarkerInstructions = BuildSceneMarkerInstructions(structuralScenes, result.SceneMappings), SceneStructure = BuildSceneStructureSummary(chapterDecisions, structuralScenes, generateSceneTitles), MarkerWarning = result.MarkerWarning }; await SaveStructuralChapterSourcesAsync(preview, result, structuralScenes); await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); await ApplyStructuralPovAsync(preview, structuralScenes, result); var canonicalSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); var locationReview = await coreImportBasicLocations.EnsurePreparedAsync( userId, preview.PreviewID, preview.ProjectID, preview.BookID, result.BuildID, canonicalSceneSources); if (locationReview is not null && await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID)) { await PrepareCharacterCandidatesAfterLocationReviewAsync(userId, preview, canonicalSceneSources); } await scanPreviews.SaveBuildResultAsync(userId, result); await PublishBuildProgress( userId, preview.PreviewID, locationReview is not null && !await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID) ? "Basic location review is ready. Review locations before character candidates." : "Updating Word document with scene markers. Keep Word open while the document is saved.", 90, progress); return result; } private async Task> AnalyseApprovedChaptersAsync( int userId, ManuscriptScanPreview preview, ManuscriptScanReviewDecision review, Book book, Func? progress) { var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList(); var structuralScenes = new List(); var knownCharacters = await BuildKnownCharacterContextAsync(preview.ProjectID); for (var chapterIndex = 0; chapterIndex < chapterDecisions.Count; chapterIndex++) { var chapterDecision = chapterDecisions[chapterIndex]; var chapterPreview = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapterDecision.TemporaryChapterKey) ?? throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' could not be found in the scan preview."); if (string.IsNullOrWhiteSpace(chapterPreview.ChapterText)) { throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' has no scanned text. Scan the manuscript again before importing."); } if (chapterPreview.ParagraphMap.Count == 0) { throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' does not have paragraph mapping. Scan the manuscript again before importing."); } var analysisPercent = 20 + (chapterIndex * 20 / Math.Max(1, chapterDecisions.Count)); await PublishBuildProgress( userId, preview.PreviewID, $"Analysing Chapter {chapterIndex + 1:N0} of {chapterDecisions.Count:N0} - {chapterDecision.Title}", analysisPercent, progress); var analysis = await structureAnalysis.AnalyseChapterAsync(new ManuscriptStructureAnalysisRequest { ProjectID = preview.ProjectID, BookID = preview.BookID, PreviewID = preview.PreviewID, TemporaryChapterKey = chapterDecision.TemporaryChapterKey, ChapterNumber = chapterDecision.ChapterNumber, SourceLabel = $"{preview.DocumentTitle ?? "Word manuscript"}: {chapterDecision.Title}", StoryEra = BookContextOptions.StoryEraLabel(book.StoryEra), PrimaryLocale = BookContextOptions.PrimaryLocaleLabel(book.PrimaryLocaleCode), KnownCharacters = knownCharacters, ChapterText = chapterPreview.ChapterText, ParagraphMap = chapterPreview.ParagraphMap, SourceParagraphs = chapterPreview.SourceParagraphs }); if (!analysis.Success) { throw new InvalidOperationException($"Scene boundary detection failed for '{chapterDecision.Title}': {analysis.ErrorMessage}"); } structuralScenes.AddRange(analysis.Scenes); } return structuralScenes; } private async Task> BuildKnownCharacterContextAsync(int projectId) { var names = new SortedSet(StringComparer.OrdinalIgnoreCase); foreach (var character in await characters.ListCharactersAsync(projectId)) { AddKnownCharacterName(names, character.CharacterName); AddKnownCharacterName(names, character.ShortName); foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) { AddKnownCharacterName(names, alias.Alias); } } return names.ToList(); } private static void AddKnownCharacterName(ISet names, string? name) { var clean = Clean(name); if (!string.IsNullOrWhiteSpace(clean)) { names.Add(clean); } } private async Task ApplyStructuralPovAsync( ManuscriptScanPreview preview, IReadOnlyList structuralScenes, OnboardingManuscriptBuildResult result) { if (structuralScenes.Count == 0 || result.SceneMappings.Count == 0) { return; } var characterIndex = await BuildCanonicalCharacterNameIndexAsync(preview.ProjectID); var chapterMap = result.ChapterMappings.ToDictionary(mapping => mapping.TemporaryChapterKey, StringComparer.Ordinal); var sceneMap = result.SceneMappings.ToDictionary(mapping => mapping.TemporarySceneKey, StringComparer.Ordinal); foreach (var chapterScenes in structuralScenes.GroupBy(scene => scene.TemporaryChapterKey)) { if (!chapterMap.TryGetValue(chapterScenes.Key, out var chapterMapping)) { continue; } var defaultSuggestion = chapterScenes .Where(scene => !scene.SuggestedPovIsOverride && !string.IsNullOrWhiteSpace(scene.SuggestedPovName)) .GroupBy(scene => Clean(scene.SuggestedPovName), StringComparer.OrdinalIgnoreCase) .OrderByDescending(group => group.Count()) .FirstOrDefault(); if (defaultSuggestion is not null) { var sample = defaultSuggestion.First(); ResolvePovCharacter(characterIndex, defaultSuggestion.Key, out var defaultCharacter); await chapters.UpdatePovCharacterAsync( chapterMapping.ChapterID, defaultCharacter?.CharacterID, "Structural", sample.SuggestedPovConfidence, sample.SuggestedPovEvidence, defaultCharacter is null ? defaultSuggestion.Key : null); } foreach (var scene in chapterScenes) { var suggestedName = Clean(scene.SuggestedPovName); if (string.IsNullOrWhiteSpace(suggestedName) || !sceneMap.TryGetValue(scene.TemporarySceneKey, out var mappedScene)) { continue; } ResolvePovCharacter(characterIndex, suggestedName, out var character); await scenes.UpdatePovCharacterAsync( mappedScene.SceneID, character?.CharacterID, scene.SuggestedPovIsOverride ? "StructuralOverride" : "StructuralInherited", scene.SuggestedPovConfidence, scene.SuggestedPovEvidence, character is null ? suggestedName : null); } } } private async Task> BuildCanonicalCharacterNameIndexAsync(int projectId) { var projectCharacters = await characters.ListCharactersAsync(projectId); var candidates = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var character in projectCharacters) { AddCharacterName(candidates, character.CharacterName, character); AddCharacterName(candidates, character.ShortName, character); AddCharacterNameParts(candidates, character.CharacterName, character); foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) { AddCharacterName(candidates, alias.Alias, character); AddCharacterNameParts(candidates, alias.Alias, character); } } return candidates .Where(item => item.Value.Count == 1) .ToDictionary(item => item.Key, item => item.Value.Values.Single(), StringComparer.OrdinalIgnoreCase); } private static void AddCharacterName(IDictionary> index, string? name, Character character) { var key = NormalisePovName(name); if (string.IsNullOrWhiteSpace(key)) { return; } if (!index.TryGetValue(key, out var matches)) { matches = new Dictionary(); index.Add(key, matches); } matches.TryAdd(character.CharacterID, character); } private static void AddCharacterNameParts(IDictionary> index, string? name, Character character) { var clean = Clean(name); if (string.IsNullOrWhiteSpace(clean)) { return; } var titleStripped = Regex.Replace(clean, @"^(mr|mrs|miss|ms|dr|doctor|aunt|uncle)\.?\s+", string.Empty, RegexOptions.IgnoreCase).Trim(); if (!string.Equals(titleStripped, clean, StringComparison.OrdinalIgnoreCase)) { AddCharacterName(index, titleStripped, character); } var tokens = titleStripped.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (tokens.Length > 0) { AddCharacterName(index, tokens[0], character); AddCharacterName(index, tokens[^1], character); } } private static string NormalisePovName(string? name) { var clean = Clean(name); if (string.IsNullOrWhiteSpace(clean)) { return string.Empty; } clean = Regex.Replace(clean, @"^(mr|mrs|miss|ms|dr|doctor|aunt|uncle)\.?\s+", string.Empty, RegexOptions.IgnoreCase).Trim(); clean = Regex.Replace(clean, @"[^\p{L}\p{N}]+", " ").Trim(); return clean.ToLowerInvariant(); } private async Task ResolvePersistedStructuralPovAsync(int projectId, int bookId) { var index = await BuildCanonicalCharacterNameIndexAsync(projectId); var resolved = 0; foreach (var chapter in await chapters.ListByBookAsync(bookId)) { var chapterScenes = await scenes.ListByChapterAsync(chapter.ChapterID); if (!string.Equals(chapter.POVSource, "Author", StringComparison.OrdinalIgnoreCase) && ResolvePovCharacter(index, chapter.POVSuggestedName ?? chapter.POVCharacterName, out var chapterCharacter)) { await chapters.UpdatePovCharacterAsync(chapter.ChapterID, chapterCharacter.CharacterID, "Structural", chapter.POVConfidence, chapter.POVEvidence, null); resolved++; } foreach (var scene in chapterScenes) { if (string.Equals(scene.POVSource, "Author", StringComparison.OrdinalIgnoreCase) || !ResolvePovCharacter(index, scene.POVSuggestedName ?? scene.POVCharacterName, out var sceneCharacter)) { continue; } await scenes.UpdatePovCharacterAsync( scene.SceneID, sceneCharacter.CharacterID, string.IsNullOrWhiteSpace(scene.POVSource) ? "StructuralResolved" : scene.POVSource, scene.POVConfidence, scene.POVEvidence, null); resolved++; } var resolvedScenePovs = chapterScenes .Where(scene => !string.Equals(scene.POVSource, "Author", StringComparison.OrdinalIgnoreCase)) .Select(scene => ResolvePovCharacter(index, scene.POVSuggestedName ?? scene.POVCharacterName, out var character) ? character.CharacterID : scene.POVCharacterID) .Where(id => id.HasValue) .Select(id => id!.Value) .Distinct() .ToList(); if (!string.Equals(chapter.POVSource, "Author", StringComparison.OrdinalIgnoreCase) && !chapter.POVCharacterID.HasValue && resolvedScenePovs.Count == 1) { await chapters.UpdatePovCharacterAsync(chapter.ChapterID, resolvedScenePovs[0], "StructuralDerived"); resolved++; } } return resolved; } private static bool ResolvePovCharacter(IReadOnlyDictionary index, string? name, out Character character) { var key = NormalisePovName(name); if (!string.IsNullOrWhiteSpace(key) && index.TryGetValue(key, out character!)) { return true; } character = null!; return false; } private async Task BuildExistingStructureResultAsync( int userId, ManuscriptScanPreview preview, ManuscriptScanReviewDecision review, OnboardingManuscriptBuildResult existingBuild) { var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); await coreImportBasicLocations.EnsurePreparedAsync(userId, preview.PreviewID, preview.ProjectID, preview.BookID, existingBuild.BuildID, sceneSources); if (await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID)) { await PrepareCharacterCandidatesAfterLocationReviewAsync(userId, preview, sceneSources); } return new OnboardingManuscriptBuildResult { BuildID = existingBuild.BuildID, PreviewID = existingBuild.PreviewID, ProjectID = existingBuild.ProjectID, BookID = existingBuild.BookID, ManuscriptDocumentID = existingBuild.ManuscriptDocumentID, Status = existingBuild.Status, MarkerStatus = existingBuild.MarkerStatus, ChaptersCreated = existingBuild.ChaptersCreated, ScenesCreated = existingBuild.ScenesCreated, CharactersCreated = existingBuild.CharactersCreated, CharactersReused = existingBuild.CharactersReused, SceneAppearancesCreated = existingBuild.SceneAppearancesCreated, AlreadyBuilt = true, Message = "Manuscript structure already exists. Updating and saving Word markers...", ChapterMappings = existingBuild.ChapterMappings, SceneMappings = existingBuild.SceneMappings, CharacterMappings = existingBuild.CharacterMappings, ChapterMarkerInstructions = BuildChapterMarkerInstructions(preview, existingBuild.ChapterMappings), SceneMarkerInstructions = BuildSceneMarkerInstructionsFromSources(sceneSources, existingBuild.SceneMappings), SceneStructure = BuildSceneStructureSummaryFromSources(review.Chapters.Where(chapter => chapter.Include).ToList(), sceneSources), MarkerWarning = existingBuild.MarkerWarning }; } private async Task PrepareCharacterCandidatesAfterLocationReviewAsync( int userId, ManuscriptScanPreview preview, IReadOnlyList sceneSources) { var protectedLocationNames = await coreImportBasicLocations.GetProtectedLocationNamesAsync(userId, preview.PreviewID); var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sceneSources, protectedLocationNames); var resolution = CoreImportCharacterCandidateResolver.ResolveExactKnownMatches( characterCandidates, await BuildExistingCharacterNameRecordsAsync(preview.ProjectID)); await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, resolution.Candidates); } private async Task> BuildExistingCharacterNameRecordsAsync(int projectId) { var names = new List(); foreach (var character in await characters.ListCharactersAsync(projectId)) { AddExistingCharacterName(names, character.CharacterID, character.CharacterName); AddExistingCharacterName(names, character.CharacterID, character.ShortName); foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) { AddExistingCharacterName(names, character.CharacterID, alias.Alias); } } return names; } private static void AddExistingCharacterName(ICollection names, int characterId, string? name) { var clean = Clean(name); if (!string.IsNullOrWhiteSpace(clean)) { names.Add(new CoreImportExistingCharacterName(characterId, clean)); } } public async Task GetBuildResultAsync(Guid previewId) { var userId = RequireUserId(); return await scanPreviews.GetBuildResultAsync(userId, previewId) ?? await builds.GetByPreviewAsync(userId, previewId); } public async Task FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form) { var context = await GetScanReviewContextAsync(form.PreviewID); if (context is null) { return null; } var (userId, preview, review, _, _, existingCharacters, contextBuild) = context.Value; var build = contextBuild ?? await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID) ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); if (build is null || build.SceneMappings.Count == 0) { throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters."); } if (!await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID)) { throw new InvalidOperationException("Review or skip basic locations before reviewing characters."); } var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal); var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet(); var autoResolvedDecisions = preview.CharacterCandidates .Where(candidate => candidate.IsAutoResolvedExistingCharacter && candidate.ExistingCharacterID.HasValue) .Select(candidate => new ManuscriptScanCharacterReviewDecision { TemporaryCharacterKey = candidate.TemporaryCharacterKey, Include = true, Name = Clean(candidate.Name), Category = candidate.Category, ExistingCharacterID = candidate.ExistingCharacterID, SexHint = CharacterSexHintService.Normalise(null, candidate.Name) }) .ToList(); var characterDecisions = autoResolvedDecisions.Concat(form.Characters .Where(item => candidateLookup.ContainsKey(item.TemporaryCharacterKey)) .Select(item => { var cleanName = Clean(item.Name); var aliasTargetKey = CleanOptional(item.AliasTargetTemporaryCharacterKey); if (!string.IsNullOrWhiteSpace(aliasTargetKey) && (!candidateLookup.ContainsKey(aliasTargetKey) || string.Equals(aliasTargetKey, item.TemporaryCharacterKey, StringComparison.Ordinal))) { aliasTargetKey = null; } var matchedExistingId = string.IsNullOrWhiteSpace(aliasTargetKey) && item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value) ? item.ExistingCharacterID : string.IsNullOrWhiteSpace(aliasTargetKey) ? MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID : null; return new ManuscriptScanCharacterReviewDecision { TemporaryCharacterKey = item.TemporaryCharacterKey, Include = item.Include, Name = cleanName, Category = item.Category, ExistingCharacterID = matchedExistingId, AliasTargetTemporaryCharacterKey = aliasTargetKey, SexHint = CharacterSexHintService.Normalise(item.SexHint, cleanName) }; })) .ToList(); ValidateScanReview(new ManuscriptScanReviewDecision { PreviewID = review.PreviewID, Status = review.Status, GenerateSceneTitles = review.GenerateSceneTitles, Chapters = review.Chapters, Scenes = review.Scenes, Characters = characterDecisions }, preview); var characterMappings = new List(); foreach (var decision in characterDecisions.Where(IsIncludedCanonicalCharacter).OrderBy(item => item.Name).ThenBy(item => item.TemporaryCharacterKey)) { var characterId = decision.ExistingCharacterID ?? MatchExistingCharacter(decision.Name, existingCharacters)?.CharacterID ?? await characters.SaveCharacterAsync(new Character { ProjectID = preview.ProjectID, CharacterName = decision.Name, Sex = string.IsNullOrWhiteSpace(decision.SexHint) ? null : decision.SexHint, CharacterImportance = 8, ShowInQuickAddBar = true }); characterMappings.Add(new OnboardingBuildCharacterMap { TemporaryCharacterKey = decision.TemporaryCharacterKey, CharacterID = characterId, Created = !decision.ExistingCharacterID.HasValue && existingCharacters.All(character => character.CharacterID != characterId) }); } var completedReview = new ManuscriptScanReviewDecision { PreviewID = review.PreviewID, Status = ManuscriptScanReviewStatuses.CoreImportComplete, GenerateSceneTitles = review.GenerateSceneTitles, Chapters = review.Chapters, Scenes = review.Scenes, Characters = characterDecisions }; var completedResult = new OnboardingManuscriptBuildResult { BuildID = build.BuildID, PreviewID = build.PreviewID, ProjectID = build.ProjectID, BookID = build.BookID, ManuscriptDocumentID = build.ManuscriptDocumentID, Status = "CoreImportComplete", MarkerStatus = build.MarkerStatus, ChaptersCreated = build.ChaptersCreated, ScenesCreated = build.ScenesCreated, CharactersCreated = characterMappings.Count(item => item.Created), CharactersReused = characterMappings.Count(item => !item.Created), SceneAppearancesCreated = build.SceneAppearancesCreated, AlreadyBuilt = build.AlreadyBuilt, Message = "Core manuscript import complete.", ChapterMappings = build.ChapterMappings, SceneMappings = build.SceneMappings, CharacterMappings = characterMappings, ChapterMarkerInstructions = build.ChapterMarkerInstructions, SceneMarkerInstructions = build.SceneMarkerInstructions, SceneStructure = build.SceneStructure, MarkerWarning = build.MarkerWarning }; await scanPreviews.SaveReviewAsync(userId, completedReview); await scanPreviews.SaveBuildResultAsync(userId, completedResult); await AddScanCharacterAliasesAsync(completedReview, completedResult); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); await ResolvePersistedStructuralPovAsync(preview.ProjectID, preview.BookID); return completedResult; } private static IReadOnlyList BuildChapterMarkerInstructions( ManuscriptScanPreview preview, IReadOnlyList mappings) { var byKey = mappings.ToDictionary(item => item.TemporaryChapterKey, StringComparer.Ordinal); return preview.Chapters .Where(chapter => chapter.StartPosition.HasValue && byKey.ContainsKey(chapter.TemporaryChapterKey)) .Select(chapter => { var map = byKey[chapter.TemporaryChapterKey]; return new OnboardingBuildMarkerInstruction { TemporaryKey = chapter.TemporaryChapterKey, EntityID = map.ChapterID, WordParagraphIndex = chapter.StartPosition!.Value, MarkerTag = $"PD-CHAPTER-{map.ChapterID}", MarkerTitle = "PlotDirector Chapter", MarkerPrefix = "PD-CHAPTER" }; }) .ToList(); } private static IReadOnlyList BuildSceneMarkerInstructions( IReadOnlyList scenes, IReadOnlyList mappings) { var byKey = mappings.ToDictionary(item => item.TemporarySceneKey, StringComparer.Ordinal); return scenes .Where(scene => byKey.ContainsKey(scene.TemporarySceneKey)) .Select(scene => { var map = byKey[scene.TemporarySceneKey]; return new OnboardingBuildMarkerInstruction { TemporaryKey = scene.TemporarySceneKey, EntityID = map.SceneID, WordParagraphIndex = scene.WordStartPosition, MarkerTag = $"PD-SCENE-{map.SceneID}", MarkerTitle = "PlotDirector Scene", MarkerPrefix = "PD-SCENE" }; }) .ToList(); } private static IReadOnlyList BuildSceneMarkerInstructionsFromSources( IReadOnlyList sources, IReadOnlyList mappings) { var sourcesBySceneId = sources .Where(source => source.WordStartParagraphIndex.HasValue) .ToDictionary(source => source.SceneID); return mappings .Where(mapping => sourcesBySceneId.ContainsKey(mapping.SceneID)) .Select(mapping => { var source = sourcesBySceneId[mapping.SceneID]; return new OnboardingBuildMarkerInstruction { TemporaryKey = mapping.TemporarySceneKey, EntityID = mapping.SceneID, WordParagraphIndex = source.WordStartParagraphIndex!.Value, MarkerTag = $"PD-SCENE-{mapping.SceneID}", MarkerTitle = "PlotDirector Scene", MarkerPrefix = "PD-SCENE" }; }) .ToList(); } private static IReadOnlyList BuildSceneStructureSummary( IReadOnlyList chapters, IReadOnlyList scenes, bool generateSceneTitles) => chapters .OrderBy(chapter => chapter.ChapterNumber) .Select(chapter => { var chapterScenes = scenes .Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal)) .OrderBy(scene => scene.SceneNumberWithinChapter) .ToList(); return new OnboardingSceneStructureSummary { TemporaryChapterKey = chapter.TemporaryChapterKey, ChapterNumber = chapter.ChapterNumber, ChapterTitle = chapter.Title, SceneCount = chapterScenes.Count, WordCount = chapterScenes.Sum(scene => scene.WordCount), Scenes = chapterScenes.Select(scene => new OnboardingSceneStructureDetail { SceneNumberWithinChapter = scene.SceneNumberWithinChapter, Title = generateSceneTitles ? scene.SuggestedTitle ?? string.Empty : string.Empty, WordCount = scene.WordCount, StructuralSummary = scene.StructuralSummary, StartParagraph = scene.StartParagraph, EndParagraph = scene.EndParagraph }).ToList() }; }) .ToList(); private static IReadOnlyList BuildSceneStructureSummaryFromSources( IReadOnlyList chapters, IReadOnlyList sources) { var byChapter = sources .GroupBy(source => source.ChapterID) .OrderBy(group => group.Min(source => source.SourceStartParagraph ?? int.MaxValue)) .ThenBy(group => group.Key) .ToList(); return chapters .OrderBy(chapter => chapter.ChapterNumber) .Select((chapter, index) => { var chapterSources = byChapter.ElementAtOrDefault(index)? .OrderBy(source => source.SourceStartParagraph ?? int.MaxValue) .ThenBy(source => source.SceneID) .ToList() ?? []; return new OnboardingSceneStructureSummary { TemporaryChapterKey = chapter.TemporaryChapterKey, ChapterNumber = chapter.ChapterNumber, ChapterTitle = chapter.Title, SceneCount = chapterSources.Count, WordCount = chapterSources.Sum(scene => scene.SourceWordCount), Scenes = chapterSources.Select((scene, sceneIndex) => new OnboardingSceneStructureDetail { SceneNumberWithinChapter = sceneIndex + 1, Title = scene.SceneTitle ?? string.Empty, WordCount = scene.SourceWordCount, StructuralSummary = scene.StructuralSummary, StartParagraph = scene.SourceStartParagraph ?? 0, EndParagraph = scene.SourceEndParagraph ?? 0 }).ToList() }; }) .ToList(); } private async Task SaveStructuralChapterSourcesAsync( ManuscriptScanPreview preview, OnboardingManuscriptBuildResult result, IReadOnlyList? structuralScenes = null) { var chapterIds = result.ChapterMappings.ToDictionary(item => item.TemporaryChapterKey, item => item.ChapterID, StringComparer.Ordinal); var chapterPov = structuralScenes? .GroupBy(scene => scene.TemporaryChapterKey) .ToDictionary(group => group.Key, group => group.FirstOrDefault(scene => !string.IsNullOrWhiteSpace(scene.SuggestedPovName)), StringComparer.Ordinal) ?? new Dictionary(StringComparer.Ordinal); foreach (var chapter in preview.Chapters) { if (!chapterIds.TryGetValue(chapter.TemporaryChapterKey, out var chapterId) || string.IsNullOrWhiteSpace(chapter.ChapterText)) { continue; } await builds.SaveStructuralChapterSourceAsync(new StructuralChapterSourceSaveRequest { ChapterID = chapterId, ProjectID = preview.ProjectID, BookID = preview.BookID, SourceText = chapter.ChapterText, SourceWordCount = CountWords(chapter.ChapterText), SourceCharacterCount = chapter.ChapterText.Length, ParagraphMapJson = JsonSerializer.Serialize(chapter.ParagraphMap), StructuralParsedJson = chapterPov.GetValueOrDefault(chapter.TemporaryChapterKey)?.ChapterParsedJson, DefaultPovName = chapterPov.GetValueOrDefault(chapter.TemporaryChapterKey)?.SuggestedPovName, ExplicitNarratorName = chapterPov.GetValueOrDefault(chapter.TemporaryChapterKey)?.ExplicitNarratorName }); } } private async Task SaveSceneManuscriptSourcesAsync( ManuscriptScanPreview preview, IReadOnlyList structuralScenes, OnboardingManuscriptBuildResult result) { var sceneIds = result.SceneMappings.ToDictionary(item => item.TemporarySceneKey, item => item.SceneID, StringComparer.Ordinal); var chapterIds = result.ChapterMappings.ToDictionary(item => item.TemporaryChapterKey, item => item.ChapterID, StringComparer.Ordinal); foreach (var scene in structuralScenes) { if (!sceneIds.TryGetValue(scene.TemporarySceneKey, out var sceneId) || !chapterIds.TryGetValue(scene.TemporaryChapterKey, out var chapterId)) { throw new InvalidOperationException("The imported scene source could not be matched to a canonical PlotDirector scene."); } if (string.IsNullOrWhiteSpace(scene.SourceText)) { throw new InvalidOperationException($"Scene {scene.SceneNumberWithinChapter:N0} source text could not be persisted because the structural source text is empty."); } await builds.SaveSceneManuscriptSourceAsync(new SceneManuscriptSourceSaveRequest { SceneID = sceneId, ProjectID = preview.ProjectID, BookID = preview.BookID, ChapterID = chapterId, SourceText = scene.SourceText, SourceWordCount = CountWords(scene.SourceText), SourceCharacterCount = scene.SourceText.Length, SourceStartParagraph = scene.StartParagraph, SourceEndParagraph = scene.EndParagraph, WordStartParagraphIndex = scene.WordStartPosition, WordEndParagraphIndex = scene.WordEndPosition }); await manuscriptTextStatistics.RecalculateSceneAsync(sceneId); if (!string.IsNullOrWhiteSpace(scene.StructuralSummary)) { await builds.SaveSceneStructuralSummaryAsync(sceneId, scene.StructuralSummary, scene.StructuralSetting); } } } private async Task PublishBuildProgress(int userId, Guid previewId, string message, int percent, Func? publish, OnboardingManuscriptBuildResult? result = null) { var state = await scanPreviews.BuildProgressAsync(userId, previewId, message, percent, result); if (publish is not null) { await publish(state); } } private async Task<(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList ExistingCharacters, OnboardingManuscriptBuildResult? Build)?> GetScanReviewContextAsync(Guid? previewId) { 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 || !project.IsOwner || project.IsArchived || book is null || book.ProjectID != project.ProjectID || book.IsArchived) { return null; } var review = await scanPreviews.GetReviewAsync(userId, preview.PreviewID); if (review is null) { return null; } var existingCharacters = await characters.ListCharactersAsync(project.ProjectID); var build = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID) ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); return (userId, preview, review, project, book, existingCharacters.Where(character => !character.IsArchived).ToList(), build); } private static ManuscriptScanReviewViewModel BuildScanReviewViewModel( (int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList ExistingCharacters, OnboardingManuscriptBuildResult? Build) context) { var (userId, preview, review, project, book, existingCharacters, build) = context; _ = userId; var chapterReview = review.Chapters.ToDictionary(item => item.TemporaryChapterKey, StringComparer.Ordinal); var sceneReview = review.Scenes.ToDictionary(item => item.TemporarySceneKey, StringComparer.Ordinal); var characterReview = review.Characters.ToDictionary(item => item.TemporaryCharacterKey, StringComparer.Ordinal); var existingOptions = existingCharacters .OrderBy(character => character.CharacterName) .Select(character => new ManuscriptScanExistingCharacterOptionViewModel { CharacterID = character.CharacterID, CharacterName = character.CharacterName }) .ToList(); var chapters = preview.Chapters .OrderBy(chapter => chapter.ChapterNumber) .Select(chapter => { chapterReview.TryGetValue(chapter.TemporaryChapterKey, out var decision); var scenes = preview.Scenes .Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal)) .OrderBy(scene => scene.SceneNumberWithinChapter) .Select(scene => { sceneReview.TryGetValue(scene.TemporarySceneKey, out var sceneDecision); return new ManuscriptScanReviewSceneViewModel { TemporarySceneKey = scene.TemporarySceneKey, TemporaryChapterKey = scene.TemporaryChapterKey, SceneNumberWithinChapter = scene.SceneNumberWithinChapter, Title = scene.Title, ReviewTitle = sceneDecision?.Title ?? (string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title), Include = sceneDecision?.Include ?? true, WordCount = scene.WordCount, OpeningTextPreview = scene.OpeningTextPreview }; }) .ToList(); return new ManuscriptScanReviewChapterViewModel { TemporaryChapterKey = chapter.TemporaryChapterKey, ChapterNumber = chapter.ChapterNumber, Title = chapter.Title, ReviewTitle = decision?.Title ?? chapter.Title, Include = decision?.Include ?? (!string.IsNullOrWhiteSpace(chapter.Title) && chapter.WordCount > 0), WordCount = chapter.WordCount, Scenes = scenes }; }) .ToList(); var characters = preview.CharacterCandidates .Where(candidate => !candidate.IsAutoResolvedExistingCharacter) .OrderByDescending(candidate => CandidateCategorySortValue(candidate.Category)) .ThenByDescending(candidate => candidate.QualityScore) .ThenByDescending(candidate => candidate.MentionCount) .ThenBy(candidate => candidate.Name) .Select(candidate => { characterReview.TryGetValue(candidate.TemporaryCharacterKey, out var decision); var reviewName = decision?.Name ?? candidate.Name; var existing = decision?.ExistingCharacterID.HasValue == true ? existingCharacters.FirstOrDefault(character => character.CharacterID == decision.ExistingCharacterID.Value) : MatchExistingCharacter(reviewName, existingCharacters); return new ManuscriptScanReviewCharacterViewModel { TemporaryCharacterKey = candidate.TemporaryCharacterKey, Name = candidate.Name, ReviewName = reviewName, Include = decision?.Include ?? string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase), MentionCount = candidate.MentionCount, QualityScore = candidate.QualityScore, Category = candidate.Category, Reason = candidate.Reason, ExistingCharacterID = existing?.CharacterID, ExistingCharacterName = existing?.CharacterName, AliasTargetTemporaryCharacterKey = decision?.AliasTargetTemporaryCharacterKey, AliasTargetName = !string.IsNullOrWhiteSpace(decision?.AliasTargetTemporaryCharacterKey) && characterReview.TryGetValue(decision.AliasTargetTemporaryCharacterKey, out var targetDecision) ? targetDecision.Name : null, SexHint = CharacterSexHintService.Normalise(decision?.SexHint, reviewName), EvidenceScenes = candidate.EvidenceScenes .OrderBy(evidence => evidence.ChapterNumber) .ThenBy(evidence => evidence.SceneNumber) .ThenBy(evidence => evidence.SceneID) .Select(evidence => new ManuscriptScanCharacterEvidenceSceneViewModel { SceneID = evidence.SceneID, ChapterID = evidence.ChapterID, ChapterNumber = evidence.ChapterNumber, SceneNumber = evidence.SceneNumber, MentionCount = evidence.MentionCount, StructuralSummary = evidence.StructuralSummary }) .ToList() }; }) .ToList(); var selectedChapterKeys = chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal); var selectedScenes = chapters.SelectMany(chapter => chapter.Scenes).Where(scene => scene.Include && selectedChapterKeys.Contains(scene.TemporaryChapterKey)).ToList(); var canonicalSceneCount = build?.ScenesCreated > 0 ? build.ScenesCreated : build?.SceneMappings.Count > 0 ? build.SceneMappings.Count : preview.SceneCount; var selectedChapterWordCount = chapters .Where(chapter => chapter.Include) .Sum(chapter => chapter.WordCount); return new ManuscriptScanReviewViewModel { PreviewID = preview.PreviewID, ReviewStatus = review.Status, GenerateSceneTitles = review.GenerateSceneTitles, DocumentTitle = string.IsNullOrWhiteSpace(preview.DocumentTitle) ? "Word manuscript" : preview.DocumentTitle!, SelectedProjectName = project.ProjectName, SelectedBookTitle = BookOptionTitle(book), TotalWordCount = preview.TotalWordCount, ChapterCount = preview.ChapterCount, SceneCount = canonicalSceneCount, CharacterCandidateCount = preview.CharacterCandidateCount, SelectedChapterCount = selectedChapterKeys.Count, SelectedSceneCount = canonicalSceneCount > 0 ? canonicalSceneCount : selectedScenes.Count, SelectedCharacterCount = characters.Count(character => character.Include && !character.IsAlias && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)), SelectedWordCount = selectedChapterWordCount, Chapters = chapters, CharacterCandidates = characters, ExistingCharacterOptions = existingOptions }; } private static void ValidateScanReview(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview) { ValidateScanStructure(review, preview); var duplicateName = review.Characters .Where(IsIncludedCanonicalCharacter) .Select(character => Clean(character.Name)) .Where(name => !string.IsNullOrWhiteSpace(name)) .GroupBy(name => name, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(group => group.Count() > 1); if (duplicateName is not null) { throw new InvalidOperationException($"The character name \"{duplicateName.Key}\" appears more than once. Exclude or rename one before continuing."); } if (review.Characters.Any(character => character.Include && string.IsNullOrWhiteSpace(character.Name))) { throw new InvalidOperationException("Every included character needs a display name."); } var characterLookup = review.Characters.ToDictionary(character => character.TemporaryCharacterKey, StringComparer.Ordinal); foreach (var character in review.Characters.Where(character => character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey))) { if (string.Equals(character.TemporaryCharacterKey, character.AliasTargetTemporaryCharacterKey, StringComparison.Ordinal)) { throw new InvalidOperationException("A character candidate cannot be an alias of itself."); } if (!characterLookup.TryGetValue(character.AliasTargetTemporaryCharacterKey!, out var target) || !IsIncludedCanonicalCharacter(target)) { throw new InvalidOperationException($"Choose an included primary character for the alias \"{character.Name}\"."); } } } private static void ValidateScanStructure(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview) { var previewChapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal); var includedChapterKeys = review.Chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal); if (includedChapterKeys.Count == 0) { throw new InvalidOperationException("Choose at least one chapter before continuing."); } if (review.Chapters.Where(chapter => chapter.Include).Any(chapter => string.IsNullOrWhiteSpace(chapter.Title))) { throw new InvalidOperationException("Every included chapter needs a title."); } if (review.Chapters.Select(chapter => chapter.ChapterNumber).Distinct().Count() != review.Chapters.Count) { throw new InvalidOperationException("Chapter order is not valid. Restore the scan defaults and try again."); } foreach (var chapter in review.Chapters) { if (!previewChapters.ContainsKey(chapter.TemporaryChapterKey)) { throw new InvalidOperationException("One of the selected chapters no longer exists in the scan preview."); } } } private async Task AddScanCharacterAliasesAsync(ManuscriptScanReviewDecision review, OnboardingManuscriptBuildResult result) { var characterIds = result.CharacterMappings.ToDictionary(item => item.TemporaryCharacterKey, item => item.CharacterID, StringComparer.Ordinal); var decisions = review.Characters.ToDictionary(item => item.TemporaryCharacterKey, StringComparer.Ordinal); foreach (var alias in review.Characters.Where(character => character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey))) { if (!decisions.TryGetValue(alias.AliasTargetTemporaryCharacterKey!, out var target) || !characterIds.TryGetValue(target.TemporaryCharacterKey, out var characterId)) { continue; } var cleanAlias = Clean(alias.Name); if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, target.Name, StringComparison.OrdinalIgnoreCase)) { continue; } var existingAliases = await characters.ListAliasesAsync(characterId); if (existingAliases.Any(item => string.Equals(item.Alias, cleanAlias, StringComparison.OrdinalIgnoreCase))) { continue; } await characters.AddAliasAsync(characterId, cleanAlias, null); } } private static bool IsIncludedCanonicalCharacter(ManuscriptScanCharacterReviewDecision character) => character.Include && string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey) && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase); private static Character? MatchExistingCharacter(string? name, IReadOnlyList existingCharacters) { var clean = Clean(name); return string.IsNullOrWhiteSpace(clean) ? null : existingCharacters.FirstOrDefault(character => string.Equals(character.CharacterName, clean, StringComparison.OrdinalIgnoreCase)); } public async Task ShouldShowOnboardingAsync() { if (!currentUser.UserId.HasValue) { return false; } var state = await onboarding.GetAsync(currentUser.UserId.Value); return state is null || state.OnboardingCompletedUtc is null; } public async Task GetDashboardNudgeAsync() { if (!currentUser.UserId.HasValue) { return new OnboardingNudgeViewModel(); } var state = await onboarding.GetAsync(currentUser.UserId.Value); if (state is not null && IsMicrosoftWordState(state)) { var status = await companionPresence.GetStatusAsync(currentUser.UserId.Value); return new OnboardingNudgeViewModel { ShouldShow = true, IsCompanionPresence = true, CompanionConnected = status.IsConnected, Title = status.IsConnected ? "Word Companion Connected" : "Connect your Word Companion", Description = status.IsConnected ? "Your Word Companion is ready for this story setup." : "Open Word, start the Companion, and sign in with your PlotDirector account.", ButtonText = status.IsConnected ? "View setup" : "Connect Companion" }; } if (state?.OnboardingCompletedUtc is not null) { return new OnboardingNudgeViewModel { ShouldShow = false }; } var hasStarted = state is not null && (!string.IsNullOrWhiteSpace(state.WritingJourney) || !string.IsNullOrWhiteSpace(state.WritingSoftware) || state.ProjectID.HasValue || state.BookID.HasValue || state.CurrentStep != OnboardingSteps.Welcome); return hasStarted ? new OnboardingNudgeViewModel { ShouldShow = true, Title = "Continue your PlotDirector setup", Description = "Pick the project and book PlotDirector should use for your first guided setup.", ButtonText = "Continue setup" } : new OnboardingNudgeViewModel { ShouldShow = true }; } public async Task ShouldShowWordCompanionHeaderAsync() { if (!currentUser.UserId.HasValue) { return false; } var state = await onboarding.GetAsync(currentUser.UserId.Value); return state is not null && IsMicrosoftWordState(state); } public async Task MoveToNextStepAsync() { var state = await GetOrCreateAsync(); var nextStep = ResolveStep(state) switch { OnboardingSteps.Welcome => OnboardingSteps.WritingJourney, OnboardingSteps.WritingJourney => OnboardingSteps.WritingSoftware, OnboardingSteps.WritingSoftware => OnboardingSteps.Project, OnboardingSteps.Project => OnboardingSteps.Book, OnboardingSteps.Book => OnboardingSteps.NextPathPreview, _ => OnboardingSteps.NextPathPreview }; await onboarding.SetCurrentStepAsync(RequireUserId(), nextStep); } public async Task StartManualSetupAsync() { var userId = RequireUserId(); await onboarding.SaveWritingJourneyAsync(userId, WritingJourneyValues.PlanningNewStory); var state = await onboarding.SaveWritingSoftwareAsync(userId, WritingSoftwareValues.Other); await scanPreviews.ClearAsync(userId, state.UserOnboardingStateID); await onboarding.SetCurrentStepAsync(userId, OnboardingSteps.Project); } public async Task StartManuscriptImportSetupAsync() { var userId = RequireUserId(); var state = await onboarding.SaveWritingJourneyAsync(userId, WritingJourneyValues.ManuscriptMostlyComplete); state = await onboarding.ClearWritingSoftwareAsync(userId); await scanPreviews.ClearAsync(userId, state.UserOnboardingStateID); await onboarding.SetCurrentStepAsync(userId, OnboardingSteps.WritingSoftware); } public async Task BeginManuscriptImportForBookAsync(int projectId, int bookId) { var userId = RequireUserId(); var project = await projects.GetForUserAsync(projectId, userId); var book = await books.GetAsync(bookId); if (project is null || !project.IsOwner || project.IsArchived || book is null || book.ProjectID != project.ProjectID || book.IsArchived) { throw new InvalidOperationException("Choose one of your active books before importing a manuscript."); } await onboarding.SaveWritingJourneyAsync(userId, WritingJourneyValues.ManuscriptMostlyComplete); await onboarding.SaveWritingSoftwareAsync(userId, WritingSoftwareValues.MicrosoftWord); await onboarding.SaveProjectAsync(userId, project.ProjectID); var state = await onboarding.SaveBookAsync(userId, book.BookID); await scanPreviews.ClearAsync(userId, state.UserOnboardingStateID); await onboarding.SetCurrentStepAsync(userId, OnboardingSteps.NextPathPreview); } public async Task SetCurrentStepAsync(string currentStep) { if (currentStep is not (OnboardingSteps.Welcome or OnboardingSteps.WritingJourney or OnboardingSteps.WritingSoftware or OnboardingSteps.Project or OnboardingSteps.Book or OnboardingSteps.NextPathPreview or OnboardingSteps.Complete)) { throw new InvalidOperationException("Choose a valid onboarding step."); } await onboarding.SetCurrentStepAsync(RequireUserId(), currentStep); } public async Task SaveWritingJourneyAsync(string writingJourney) { if (!WritingJourneys.Contains(writingJourney)) { throw new InvalidOperationException("Choose a writing journey option."); } await onboarding.SaveWritingJourneyAsync(RequireUserId(), writingJourney); } public async Task SaveWritingSoftwareAsync(string writingSoftware) { if (!WritingSoftware.Contains(writingSoftware)) { throw new InvalidOperationException("Choose a writing software option."); } await onboarding.SaveWritingSoftwareAsync(RequireUserId(), writingSoftware); } public async Task SaveProjectAsync(ProjectOnboardingForm form) { var userId = RequireUserId(); if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal)) { var projectId = form.SelectedProjectID.GetValueOrDefault(); var project = projectId > 0 ? await projects.GetForUserAsync(projectId, userId) : null; if (project is null || !project.IsOwner || project.IsArchived) { throw new InvalidOperationException("Choose one of your active projects."); } await onboarding.SaveProjectAsync(userId, project.ProjectID); return; } if (!string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal)) { throw new InvalidOperationException("Choose whether to create or use an existing project."); } var projectName = Clean(form.ProjectName); if (string.IsNullOrWhiteSpace(projectName)) { throw new InvalidOperationException("Enter a project name."); } var projectIdCreated = await projects.SaveAsync(new Project { ProjectName = projectName, Description = CleanOptional(form.Description) }, userId, "Custom"); await activity.RecordAsync(projectIdCreated, "Created", "Project", projectIdCreated, projectName); await onboarding.SaveProjectAsync(userId, projectIdCreated); } public async Task SaveBookAsync(BookOnboardingForm form) { var userId = RequireUserId(); var state = await GetOrCreateAsync(); var projectId = state.ProjectID.GetValueOrDefault(); var project = projectId > 0 ? await projects.GetForUserAsync(projectId, userId) : null; if (project is null || !project.IsOwner || project.IsArchived) { throw new InvalidOperationException("Choose a project before choosing a book."); } if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal)) { var bookId = form.SelectedBookID.GetValueOrDefault(); var book = bookId > 0 ? await books.GetAsync(bookId) : null; if (book is null || book.ProjectID != project.ProjectID || book.IsArchived) { throw new InvalidOperationException("Choose a book from the selected project."); } await onboarding.SaveBookAsync(userId, book.BookID); return; } if (!string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal)) { throw new InvalidOperationException("Choose whether to create or use an existing book."); } var bookTitle = Clean(form.BookTitle); if (string.IsNullOrWhiteSpace(bookTitle)) { throw new InvalidOperationException("Enter a book title."); } var createModel = await bookService.GetCreateAsync(project.ProjectID) ?? throw new InvalidOperationException("The selected project could not be used."); createModel.BookTitle = bookTitle; createModel.Subtitle = CleanOptional(form.Subtitle); createModel.TargetWordCount = form.TargetWordCount; createModel.StoryEra = CleanOptional(form.StoryEra); createModel.PrimaryLocaleCode = CleanOptional(form.PrimaryLocaleCode); var result = await bookService.SaveAsync(createModel); if (!result.Succeeded) { throw new InvalidOperationException(result.ErrorMessage ?? "The book could not be created."); } await onboarding.SaveBookAsync(userId, result.BookID); } public async Task MarkCompletedAsync() => await onboarding.MarkCompletedAsync(RequireUserId()); private int RequireUserId() => currentUser.UserId ?? throw new InvalidOperationException("Sign in to use onboarding."); private static string ResolveStep(UserOnboardingState state) { if (state.OnboardingCompletedUtc.HasValue) { return OnboardingSteps.NextPathPreview; } if (state.CurrentStep is OnboardingSteps.Welcome or OnboardingSteps.WritingJourney or OnboardingSteps.WritingSoftware or OnboardingSteps.Project or OnboardingSteps.Book or OnboardingSteps.NextPathPreview) { return state.CurrentStep; } if (string.IsNullOrWhiteSpace(state.WritingJourney)) { return OnboardingSteps.WritingJourney; } if (string.IsNullOrWhiteSpace(state.WritingSoftware)) { return OnboardingSteps.WritingSoftware; } if (!state.ProjectID.HasValue) { return OnboardingSteps.Project; } if (!state.BookID.HasValue) { return OnboardingSteps.Book; } return OnboardingSteps.NextPathPreview; } private static int StepNumber(string step) => step switch { OnboardingSteps.Welcome => 1, OnboardingSteps.WritingJourney => 2, OnboardingSteps.WritingSoftware => 3, OnboardingSteps.Project => 4, OnboardingSteps.Book => 5, _ => 6 }; private async Task> BuildProjectOptionsAsync() { var userId = RequireUserId(); var rows = await projects.ListActiveForUserAsync(userId); return rows .Where(project => project.IsOwner && !project.IsArchived) .OrderBy(project => project.ProjectName) .Select(project => new OnboardingProjectOptionViewModel { ProjectID = project.ProjectID, ProjectName = project.ProjectName, Description = string.IsNullOrWhiteSpace(project.Description) ? $"Updated {project.UpdatedDate:yyyy-MM-dd}" : project.Description }) .ToList(); } private async Task> BuildBookOptionsAsync(int projectId) { var project = await projects.GetForUserAsync(projectId, RequireUserId()); if (project is null || !project.IsOwner || project.IsArchived) { return []; } var rows = await books.ListByProjectAsync(projectId); return rows .Where(book => !book.IsArchived) .OrderBy(book => book.SortOrder) .ThenBy(book => book.BookNumber) .ThenBy(book => book.BookTitle) .Select(book => new OnboardingBookOptionViewModel { BookID = book.BookID, DisplayTitle = BookOptionTitle(book), Detail = BookOptionDetail(book) }) .ToList(); } private static string BookOptionTitle(Book book) => $"Book {book.BookNumber}: {book.BookDisplayTitle}"; private static string BookOptionDetail(Book book) { var parts = new List { book.Status }; if (book.TargetWordCount is > 0) { parts.Add($"{book.TargetWordCount.Value:N0} target words"); } parts.Add($"Updated {book.UpdatedDate:yyyy-MM-dd}"); return string.Join(" / ", parts); } private static string Clean(string? value) => (value ?? string.Empty).Trim(); private static int CountWords(string value) => string.IsNullOrWhiteSpace(value) ? 0 : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; private static bool IsMicrosoftWordState(UserOnboardingState state) => string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal) || state.UsesWordCompanion == true; private static CompanionPresenceViewModel ToPresenceViewModel(WordCompanionPresenceStatus status) => 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, ReviewStatus = state.ReviewStatus, Message = state.Message, PercentComplete = state.PercentComplete, ChapterCount = state.ChapterCount, SceneCount = state.SceneCount, CharacterCandidateCount = state.CharacterCandidateCount, TotalWordCount = state.TotalWordCount, PreviewID = state.PreviewID }; private static int CandidateCategorySortValue(string? category) => string.Equals(category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase) ? 4 : string.Equals(category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase) ? 3 : string.Equals(category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase) ? 2 : 1; private static string? CleanOptional(string? value) { var cleaned = Clean(value); return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned; } private static IReadOnlyList WritingJourneyOptions() => [ new() { Value = WritingJourneyValues.PlanningNewStory, Title = "I am planning a brand new story", Description = "You are shaping the idea, structure, characters, or world before drafting." }, new() { Value = WritingJourneyValues.AlreadyStarted, Title = "I have already started writing", Description = "You have draft material and want PlotDirector to help you organise what exists." }, new() { Value = WritingJourneyValues.ManuscriptMostlyComplete, Title = "My manuscript is largely complete", Description = "You are ready to review structure, continuity, and story detail across the manuscript." } ]; private static IReadOnlyList WritingSoftwareOptions() => [ new() { Value = WritingSoftwareValues.MicrosoftWord, Title = "Microsoft Word", Description = "Best fit for the PlotDirector Word Companion workflow.", Badge = "Recommended" }, new() { Value = WritingSoftwareValues.GoogleDocs, Title = "Google Docs", Description = "Use exports later when manuscript import is added." }, new() { Value = WritingSoftwareValues.Scrivener, Title = "Scrivener", Description = "Use a manuscript export later when import support arrives." }, new() { Value = WritingSoftwareValues.LibreOfficeOpenOffice, Title = "LibreOffice / OpenOffice", Description = "Good for document-based workflows outside Microsoft Word." }, new() { Value = WritingSoftwareValues.MarkdownObsidian, Title = "Markdown / Obsidian", Description = "For plain-text writing systems and linked-note workflows." }, new() { Value = WritingSoftwareValues.Other, Title = "Other", Description = "PlotDirector can still tailor future setup around your tools." } ]; }