PlotDirector/PlotLine/Services/OnboardingService.cs
2026-07-03 23:33:03 +01:00

913 lines
40 KiB
C#

using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IOnboardingService
{
Task<UserOnboardingState> GetOrCreateAsync();
Task<OnboardingWizardViewModel> GetWizardAsync();
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null);
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId);
Task<bool> ShouldShowOnboardingAsync();
Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
Task<bool> ShouldShowWordCompanionHeaderAsync();
Task MoveToNextStepAsync();
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<string> WritingJourneys = new(StringComparer.Ordinal)
{
WritingJourneyValues.PlanningNewStory,
WritingJourneyValues.AlreadyStarted,
WritingJourneyValues.ManuscriptMostlyComplete
};
private static readonly HashSet<string> WritingSoftware = new(StringComparer.Ordinal)
{
WritingSoftwareValues.MicrosoftWord,
WritingSoftwareValues.GoogleDocs,
WritingSoftwareValues.Scrivener,
WritingSoftwareValues.LibreOfficeOpenOffice,
WritingSoftwareValues.MarkdownObsidian,
WritingSoftwareValues.Other
};
public async Task<UserOnboardingState> GetOrCreateAsync()
=> await onboarding.GetOrCreateAsync(RequireUserId());
public async Task<OnboardingWizardViewModel> 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),
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<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null)
{
var context = await GetScanReviewContextAsync(previewId);
return context is null ? null : BuildScanReviewViewModel(context.Value);
}
public async Task<ManuscriptScanReviewViewModel?> 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<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? 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 building the project structure.");
}
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();
var chapterKeys = chapterDecisions.Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
var sceneDecisions = review.Scenes
.Where(scene => scene.Include && chapterKeys.Contains(scene.TemporaryChapterKey))
.OrderBy(scene => chapterDecisions.FindIndex(chapter => chapter.TemporaryChapterKey == scene.TemporaryChapterKey))
.ThenBy(scene => scene.SceneNumberWithinChapter)
.ToList();
var characterDecisions = review.Characters
.Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.ToList();
await PublishBuildProgress(userId, preview.PreviewID, "Creating scenes...", 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 = sceneDecisions.Select((scene, index) => new OnboardingBuildSceneRequest
{
TemporarySceneKey = scene.TemporarySceneKey,
TemporaryChapterKey = scene.TemporaryChapterKey,
Title = string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title!,
SortOrder = (index + 1) * 10,
WordCount = preview.Scenes.FirstOrDefault(item => item.TemporarySceneKey == scene.TemporarySceneKey)?.WordCount ?? 0
}).ToList(),
Characters = characterDecisions.Select(character => new OnboardingBuildCharacterRequest
{
TemporaryCharacterKey = character.TemporaryCharacterKey,
Name = character.Name,
ExistingCharacterID = character.ExistingCharacterID
}).ToList()
};
await PublishBuildProgress(userId, preview.PreviewID, "Creating characters...", 60, progress);
var result = await builds.BuildAsync(userId, request)
?? throw new InvalidOperationException($"The project structure could not be built. Approved chapters: {request.Chapters.Count}, scenes: {request.Scenes.Count}, characters: {request.Characters.Count}. Return to review, save your selections, and try Build Project again.");
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<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId)
=> await scanPreviews.GetBuildResultAsync(RequireUserId(), previewId);
private async Task PublishBuildProgress(int userId, Guid previewId, string message, int percent, Func<OnboardingBuildProgress, Task>? 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<Character> 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<Character> 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<Character> existingCharacters)
{
var clean = Clean(name);
return string.IsNullOrWhiteSpace(clean)
? null
: existingCharacters.FirstOrDefault(character => string.Equals(character.CharacterName, clean, StringComparison.OrdinalIgnoreCase));
}
public async Task<bool> 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<OnboardingNudgeViewModel> 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<bool> 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 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<IReadOnlyList<OnboardingProjectOptionViewModel>> 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<IReadOnlyList<OnboardingBookOptionViewModel>> 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<string> { 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<OnboardingOptionViewModel> 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<OnboardingOptionViewModel> 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."
}
];
}