using System.Text.Json; 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 FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form); Task BuildApprovedStructureAsync(Guid previewId, 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, IOnboardingBuildRepository builds, IManuscriptStructureAnalysisService structureAnalysis, ICoreImportCharacterMappingService coreImportCharacterMapping, 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 }, 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, 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, Chapters = review.Chapters, Scenes = review.Scenes, Characters = review.Characters }; } else { ValidateScanStructure(review, preview); } var saved = await scanPreviews.SaveReviewAsync(userId, review); return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters)); } public async Task BuildApprovedStructureAsync(Guid previewId, Func? progress = null) { var context = await GetScanReviewContextAsync(previewId); if (context is null) { return null; } var (userId, preview, review, _, _, _) = 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."); } var existingBuild = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID) ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); if (existingBuild is not null && existingBuild.SceneMappings.Count > 0) { var resumed = await BuildExistingStructureResultAsync(userId, preview, review, existingBuild); await scanPreviews.SaveBuildResultAsync(userId, resumed); await PublishBuildProgress(userId, preview.PreviewID, "Resuming Word marker sync. No scene detection was rerun.", 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 = new List(); 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}", 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); } 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 = scene.Title, 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), MarkerWarning = result.MarkerWarning }; await SaveStructuralChapterSourcesAsync(preview, result); await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); var canonicalSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(canonicalSceneSources); await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates); await scanPreviews.SaveBuildResultAsync(userId, result); await PublishBuildProgress(userId, preview.PreviewID, "Updating Word document with scene markers. Keep Word open while the document is saved.", 90, progress); return result; } private async Task BuildExistingStructureResultAsync( int userId, ManuscriptScanPreview preview, ManuscriptScanReviewDecision review, OnboardingManuscriptBuildResult existingBuild) { var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sceneSources); await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates); 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 }; } public async Task GetBuildResultAsync(Guid previewId) => await scanPreviews.GetBuildResultAsync(RequireUserId(), previewId); public async Task FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form) { var context = await GetScanReviewContextAsync(form.PreviewID); if (context is null) { return null; } var (userId, preview, review, _, _, existingCharacters) = context.Value; var build = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID); if (build is null || build.SceneMappings.Count == 0) { throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters."); } var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal); var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet(); 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(); ValidateScanReview(new ManuscriptScanReviewDecision { PreviewID = review.PreviewID, Status = review.Status, 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, 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); 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) => 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 = scene.Title, 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 {sceneIndex + 1:N0}", WordCount = scene.SourceWordCount, StructuralSummary = scene.StructuralSummary, StartParagraph = scene.SourceStartParagraph ?? 0, EndParagraph = scene.SourceEndParagraph ?? 0 }).ToList() }; }) .ToList(); } private async Task SaveStructuralChapterSourcesAsync(ManuscriptScanPreview preview, OnboardingManuscriptBuildResult result) { var chapterIds = result.ChapterMappings.ToDictionary(item => item.TemporaryChapterKey, item => item.ChapterID, 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) }); } } 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 }); if (!string.IsNullOrWhiteSpace(scene.StructuralSummary)) { await builds.SaveSceneStructuralSummaryAsync(sceneId, scene.StructuralSummary); } } } 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)?> 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); return (userId, preview, review, project, book, existingCharacters.Where(character => !character.IsArchived).ToList()); } private static ManuscriptScanReviewViewModel BuildScanReviewViewModel( (int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList ExistingCharacters) context) { var (userId, preview, review, project, book, existingCharacters) = 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 .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) }; }) .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 selectedChapterWordCount = chapters .Where(chapter => chapter.Include) .Sum(chapter => chapter.WordCount); return new ManuscriptScanReviewViewModel { PreviewID = preview.PreviewID, ReviewStatus = review.Status, 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, SelectedChapterCount = selectedChapterKeys.Count, SelectedSceneCount = 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; 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." } ]; }