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 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, 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 = 9, 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 matchedExistingId = item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value) ? item.ExistingCharacterID : MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID; return new ManuscriptScanCharacterReviewDecision { TemporaryCharacterKey = item.TemporaryCharacterKey, Include = item.Include, Name = cleanName, Category = item.Category, ExistingCharacterID = matchedExistingId }; }) .ToList(); var review = new ManuscriptScanReviewDecision { PreviewID = preview.PreviewID, Status = readyToImport ? ManuscriptScanReviewStatuses.ReadyToImport : ManuscriptScanReviewStatuses.ReviewInProgress, Chapters = chapterDecisions, Scenes = sceneDecisions, Characters = characterDecisions }; ValidateScanReview(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."); } await PublishBuildProgress(userId, preview.PreviewID, "Creating chapters...", 10, progress); ValidateScanReview(review, preview); var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList(); await PublishBuildProgress(userId, preview.PreviewID, "Preparing chapters for analysis...", 35, 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 = [], Characters = [] }; await PublishBuildProgress(userId, preview.PreviewID, "Saving chapters...", 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 = "Approved chapters are ready for Story Intelligence.", ChapterMappings = result.ChapterMappings, SceneMappings = result.SceneMappings, CharacterMappings = result.CharacterMappings, MarkerWarning = result.MarkerWarning }; await scanPreviews.SaveBuildResultAsync(userId, result); await PublishBuildProgress(userId, preview.PreviewID, "Finalising project...", 90, progress); await PublishBuildProgress(userId, preview.PreviewID, result.Message, 100, progress, result); return result; } public async Task GetBuildResultAsync(Guid previewId) => await scanPreviews.GetBuildResultAsync(RequireUserId(), previewId); 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 }; }) .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(); 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 && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)), SelectedWordCount = selectedScenes.Sum(scene => scene.WordCount), Chapters = chapters, CharacterCandidates = characters, ExistingCharacterOptions = existingOptions }; } private static void ValidateScanReview(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview) { var previewChapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal); var previewScenes = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, 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.Scenes.Any(scene => scene.Include && includedChapterKeys.Contains(scene.TemporaryChapterKey))) { throw new InvalidOperationException("Choose at least one scene 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 scene in review.Scenes.Where(scene => scene.Include)) { if (!includedChapterKeys.Contains(scene.TemporaryChapterKey)) { throw new InvalidOperationException("Included scenes must belong to included chapters."); } if (!previewScenes.TryGetValue(scene.TemporarySceneKey, out var previewScene)) { throw new InvalidOperationException("One of the selected scenes no longer exists in the scan preview."); } if (previewScene.WordCount <= 0 && !scene.AllowZeroWords) { throw new InvalidOperationException("Included scenes with no words must be explicitly allowed."); } } 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."); } } var duplicateName = review.Characters .Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)) .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."); } } 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(); await onboarding.SaveWritingJourneyAsync(userId, WritingJourneyValues.ManuscriptMostlyComplete); var state = await onboarding.SaveWritingSoftwareAsync(userId, WritingSoftwareValues.MicrosoftWord); await scanPreviews.ClearAsync(userId, state.UserOnboardingStateID); await onboarding.SetCurrentStepAsync(userId, OnboardingSteps.Project); } 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 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." } ]; }