579 lines
22 KiB
C#
579 lines
22 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<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,
|
|
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 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 || book is null || book.ProjectID != project.ProjectID)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new ManuscriptScanReviewViewModel
|
|
{
|
|
PreviewID = preview.PreviewID,
|
|
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,
|
|
Chapters = preview.Chapters
|
|
.OrderBy(chapter => chapter.ChapterNumber)
|
|
.Select(chapter => new ManuscriptScanReviewChapterViewModel
|
|
{
|
|
TemporaryChapterKey = chapter.TemporaryChapterKey,
|
|
ChapterNumber = chapter.ChapterNumber,
|
|
Title = chapter.Title,
|
|
WordCount = chapter.WordCount,
|
|
Scenes = preview.Scenes
|
|
.Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal))
|
|
.OrderBy(scene => scene.SceneNumberWithinChapter)
|
|
.ToList()
|
|
})
|
|
.ToList(),
|
|
CharacterCandidates = preview.CharacterCandidates
|
|
.OrderByDescending(candidate => candidate.MentionCount)
|
|
.ThenBy(candidate => candidate.Name)
|
|
.ToList()
|
|
};
|
|
}
|
|
|
|
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,
|
|
Message = state.Message,
|
|
PercentComplete = state.PercentComplete,
|
|
ChapterCount = state.ChapterCount,
|
|
SceneCount = state.SceneCount,
|
|
CharacterCandidateCount = state.CharacterCandidateCount,
|
|
TotalWordCount = state.TotalWordCount,
|
|
PreviewID = state.PreviewID
|
|
};
|
|
|
|
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."
|
|
}
|
|
];
|
|
}
|