using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; using System.Diagnostics; namespace PlotLine.Controllers; [Authorize] [Route("onboarding")] public sealed class OnboardingController( IOnboardingService onboarding, IOnboardingStoryIntelligenceService storyIntelligence, IWholeBookPlotIntelligenceService wholeBookPlotIntelligence, ICurrentUserService currentUser, ILogger logger) : Controller { [HttpGet("")] public async Task Index() { var model = await onboarding.GetWizardAsync(); return View(model); } [HttpPost("start-manual")] [ValidateAntiForgeryToken] public async Task StartManual() { await onboarding.StartManualSetupAsync(); return RedirectToAction(nameof(Index)); } [HttpPost("start-import")] [ValidateAntiForgeryToken] public async Task StartImport() { await onboarding.StartManuscriptImportSetupAsync(); return RedirectToAction(nameof(Index)); } [HttpGet("scan-review")] public async Task ScanReview(Guid? previewId) { var model = await onboarding.GetScanReviewAsync(previewId) ?? (previewId.HasValue ? await onboarding.GetScanReviewAsync() : null); if (model is null) { TempData["ArchiveError"] = "The scan review could not be opened. Please scan the manuscript again."; await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); return RedirectToAction(nameof(Index)); } return View(model); } [HttpGet("scan-characters")] public async Task ScanCharacters(Guid? previewId, int page = 1) { var model = await onboarding.GetScanReviewAsync(previewId) ?? (previewId.HasValue ? await onboarding.GetScanReviewAsync() : null); if (model is null) { TempData["ArchiveError"] = "The character review could not be opened. Please scan the manuscript again."; await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); return RedirectToAction(nameof(Index)); } var build = await onboarding.GetBuildResultAsync(model.PreviewID); if (build is null || build.SceneMappings.Count == 0) { TempData["ArchiveError"] = "Import the approved chapters and scenes before reviewing characters."; await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); return RedirectToAction(nameof(Index)); } if (!await onboarding.IsBasicLocationReviewCompleteAsync(model.PreviewID)) { TempData["OnboardingReviewMessage"] = "Review or skip basic locations before reviewing characters."; return RedirectToAction(nameof(ScanLocations), new { previewId = model.PreviewID }); } await onboarding.EnsureCharacterCandidatesAfterLocationReviewAsync(model.PreviewID); model = await onboarding.GetScanReviewAsync(model.PreviewID); if (model is null) { return NotFound(); } ViewData["ScanReviewStep"] = "characters"; ViewData["CharacterReviewPage"] = Math.Max(1, page); return View("ScanReview", model); } [HttpGet("scan-locations")] [HttpGet("scan/locations")] public async Task ScanLocations(Guid previewId) { var model = await onboarding.GetBasicLocationReviewAsync(previewId); if (model is null) { TempData["ArchiveError"] = "Import the approved chapters and scenes before reviewing locations."; await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); return RedirectToAction(nameof(Index)); } if (model.IsComplete) { return RedirectToAction(nameof(ScanCharacters), new { previewId }); } return View(model); } [HttpPost("scan-locations")] [HttpPost("scan/locations")] [RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)] [ValidateAntiForgeryToken] public async Task SaveBasicLocations(CoreImportBasicLocationReviewForm form, string intent = "save") { try { if (string.Equals(intent, "skip", StringComparison.OrdinalIgnoreCase)) { await onboarding.SkipBasicLocationReviewAsync(form.PreviewID); TempData["OnboardingReviewMessage"] = "Basic location review skipped. Next: review detected characters."; return RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID }); } if (string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase)) { await onboarding.CompleteBasicLocationReviewAsync(form); TempData["OnboardingReviewMessage"] = "Locations saved. Next: review detected characters."; return RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID }); } await onboarding.SaveBasicLocationReviewAsync(form); TempData["OnboardingReviewMessage"] = "Location review choices saved."; return RedirectToAction(nameof(ScanLocations), new { previewId = form.PreviewID }); } catch (InvalidOperationException ex) { TempData["ArchiveError"] = ex.Message; return RedirectToAction(nameof(ScanLocations), new { previewId = form.PreviewID }); } } [HttpGet("build-complete")] public async Task BuildComplete(Guid previewId) { var model = await onboarding.GetBuildResultAsync(previewId); return model is null ? NotFound() : View(model); } [HttpGet("story-intelligence")] public async Task StoryIntelligence() { var model = await storyIntelligence.GetOverviewAsync(); if (model.ExistingBatchID.HasValue) { return RedirectToBook(model); } if (model.PreviewID == Guid.Empty) { return RedirectToAction(nameof(Index)); } return RedirectToAction(nameof(BuildComplete), new { previewId = model.PreviewID }); } [HttpGet("story-intelligence/context")] public async Task StoryIntelligenceContext() { return await StoryIntelligence(); } [HttpGet("story-intelligence/book/{bookId:int}/continue")] public async Task ContinueStoryIntelligence(int bookId) => await StoryIntelligenceBookReviewCentre(bookId); [HttpGet("story-intelligence/book/{bookId:int}/review-centre")] public async Task StoryIntelligenceBookReviewCentre(int bookId) { var target = await storyIntelligence.ResumeBookAsync(bookId); if (target is null) { TempData["ArchiveError"] = "Story Intelligence could not find saved analysis for that book."; return RedirectToAction("Details", "Books", new { id = bookId }); } return target.Route switch { StoryIntelligenceResumeRoutes.ReviewCentre => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Complete => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Narrative => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Knowledge => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Relationships => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Assets => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Locations => await StoryIntelligenceReviewCentre(target.BatchID), StoryIntelligenceResumeRoutes.Characters => await StoryIntelligenceReviewCentre(target.BatchID), _ => RedirectToAction("Details", "Books", new { id = bookId }) }; } [HttpPost("story-intelligence/start")] [ValidateAntiForgeryToken] public async Task StartStoryIntelligence() => RedirectToAction(nameof(StoryIntelligencePlotContext)); [HttpGet("story-intelligence/plot-context")] public async Task StoryIntelligencePlotContext() { var overview = await storyIntelligence.GetOverviewAsync(); if (!overview.BookID.HasValue || !overview.CanStart) { TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence."; return RedirectToAction(nameof(Index)); } var model = await wholeBookPlotIntelligence.GetAuthorPlotContextAsync(overview.BookID.Value); if (model is null) { return RedirectToAction(nameof(Index)); } return View("StoryIntelligenceAuthorPlotContext", model); } [HttpGet("story-intelligence/book/{bookId:int}/plot-context")] public async Task StoryIntelligenceBookPlotContext(int bookId) { var existing = await storyIntelligence.ResumeBookAsync(bookId); if (existing is not null) { TempData["ArchiveMessage"] = "Story Intelligence is already available for this book. Progress is shown in the page header."; return RedirectToAction("Details", "Books", new { id = bookId }); } var model = await wholeBookPlotIntelligence.GetAuthorPlotContextAsync(bookId); if (model is null) { TempData["ArchiveError"] = "Story Intelligence could not find that book."; return RedirectToAction("Details", "Books", new { id = bookId }); } return View("StoryIntelligenceAuthorPlotContext", model); } [HttpPost("story-intelligence/plot-context/start")] [ValidateAntiForgeryToken] public async Task StartStoryIntelligenceWithPlotContext(StoryIntelligenceAuthorPlotContextForm form) { if (form.ProjectID <= 0 || form.BookID <= 0) { TempData["OnboardingStoryIntelligenceError"] = "Story Intelligence could not find the selected book."; return RedirectToAction(nameof(Index)); } await wholeBookPlotIntelligence.SaveAuthorPlotContextAsync(form.ProjectID, form.BookID, form.PlotSeeds); OnboardingStoryIntelligenceBatch? job; try { var overview = await storyIntelligence.GetOverviewAsync(); var isCurrentOnboardingBook = overview.BookID == form.BookID && overview.PreviewID != Guid.Empty && overview.CanStart; job = isCurrentOnboardingBook ? await storyIntelligence.StartAsync() : await storyIntelligence.StartBookAsync(form.BookID); } catch (InvalidOperationException ex) { TempData["ArchiveError"] = ex.Message; return RedirectToAction("Details", "Books", new { id = form.BookID }); } if (job is null) { TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence."; return RedirectToAction("Details", "Books", new { id = form.BookID }); } var progress = await storyIntelligence.GetProgressAsync(job.BatchID); if (progress is not null) { TempData["ArchiveMessage"] = "Story Intelligence is now analysing your book. Progress is shown in the page header."; return RedirectToBook(progress); } return RedirectToAction("Details", "Books", new { id = job.BookID }); } [HttpPost("story-intelligence/book/{bookId:int}/start")] [ValidateAntiForgeryToken] public async Task StartBookStoryIntelligence(int bookId) { var existing = await storyIntelligence.ResumeBookAsync(bookId); if (existing is not null) { TempData["ArchiveMessage"] = "Story Intelligence is already analysing this book. Progress is shown in the page header."; return RedirectToAction("Details", "Books", new { id = bookId }); } return RedirectToAction(nameof(StoryIntelligenceBookPlotContext), new { bookId }); } [HttpPost("story-intelligence/skip")] [ValidateAntiForgeryToken] public async Task SkipStoryIntelligence() { await onboarding.MarkCompletedAsync(); TempData["ArchiveMessage"] = "Story Intelligence skipped for now. You can return to it from your dashboard."; return RedirectToAction("Index", "Projects"); } [HttpPost("scan-manuscript")] [ValidateAntiForgeryToken] public async Task ScanManuscript() { await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); return RedirectToAction(nameof(Index)); } [HttpGet("story-intelligence/progress")] public async Task StoryIntelligenceProgress(Guid batchId) { var model = await storyIntelligence.GetProgressAsync(batchId); if (model is null) { return NotFound(); } return model.SceneAnalysisComplete ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }) : RedirectToBook(model); } [HttpGet("story-intelligence/review")] public async Task StoryIntelligenceReview(Guid batchId) { var model = await storyIntelligence.GetProgressAsync(batchId); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToBook(model); } return model.SceneAnalysisComplete ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }) : RedirectToBook(model); } [HttpGet("story-intelligence/review-centre")] public async Task StoryIntelligenceReviewCentre(Guid batchId) { var stopwatch = Stopwatch.StartNew(); var model = await storyIntelligence.GetReviewCentreAsync(batchId); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToBook(model); } logger.LogInformation( "Story Intelligence Review Centre GET rendered. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} ElapsedMs={ElapsedMs} ActiveRuns={ActiveRuns} SceneAnalysisComplete={SceneAnalysisComplete}", batchId, model.ProjectID, model.BookID, stopwatch.ElapsedMilliseconds, model.HasActiveRuns, model.SceneAnalysisComplete); return View(nameof(StoryIntelligenceReviewCentre), model); } [HttpGet("story-intelligence/characters")] public async Task StoryIntelligenceCharacters(Guid batchId, int page = 1) { var model = await storyIntelligence.GetCharacterReviewAsync(batchId, page); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } return View(model); } [HttpGet("story-intelligence/locations")] public async Task StoryIntelligenceLocations(Guid batchId, int page = 1) { var model = await storyIntelligence.GetLocationReviewAsync(batchId, page); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } return !model.CharacterGateSatisfied ? RedirectToReviewCentreWithCharacterGate(batchId) : View(model); } [HttpGet("story-intelligence/assets")] public async Task StoryIntelligenceAssets(Guid batchId, int page = 1) { var model = await storyIntelligence.GetAssetReviewAsync(batchId, page); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } if (!model.CharacterGateSatisfied) { return RedirectToReviewCentreWithCharacterGate(batchId); } return View(model); } [HttpGet("story-intelligence/relationships")] public async Task StoryIntelligenceRelationships(Guid batchId, int page = 1) { var model = await storyIntelligence.GetRelationshipReviewAsync(batchId, page); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } if (!model.CharacterGateSatisfied) { return RedirectToReviewCentreWithCharacterGate(batchId); } return View(model); } [HttpPost("story-intelligence/cancel")] [ValidateAntiForgeryToken] public async Task CancelStoryIntelligence(Guid batchId) { var progress = await storyIntelligence.CancelAsync(batchId); if (progress is null) { return NotFound(); } TempData["OnboardingStoryIntelligenceMessage"] = "Analysis cancellation requested."; return RedirectToBook(progress); } [HttpPost("story-intelligence/commit")] [ValidateAntiForgeryToken] public async Task CommitStoryIntelligence(Guid batchId, int runId) { var (progress, result) = await storyIntelligence.CommitAsync(batchId, runId); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } [HttpPost("story-intelligence/commit-all")] [ValidateAntiForgeryToken] public async Task CommitAllStoryIntelligence(Guid batchId) { var (progress, result) = await storyIntelligence.CommitAllAsync(batchId); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } [HttpPost("story-intelligence/characters")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceCharacters(StoryIntelligenceCharacterImportForm form) { var (progress, result) = await storyIntelligence.ImportCharactersAsync(form.BatchID, form); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = form.BatchID }); } [HttpPost("story-intelligence/locations")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form) { var (progress, result) = await storyIntelligence.ImportLocationsAsync(form.BatchID, form); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID }); } [HttpPost("story-intelligence/assets")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form) { var (progress, result) = await storyIntelligence.ImportAssetsAsync(form.BatchID, form); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID }); } [HttpPost("story-intelligence/relationships")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceRelationships(StoryIntelligenceRelationshipImportForm form) { var (progress, result) = await storyIntelligence.ImportRelationshipsAsync(form.BatchID, form); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID }); } [HttpGet("story-intelligence/knowledge")] public async Task StoryIntelligenceKnowledge(Guid batchId, int page = 1) { var model = await storyIntelligence.GetKnowledgeReviewAsync(batchId, page); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } if (!model.CharacterGateSatisfied) { return RedirectToReviewCentreWithCharacterGate(batchId); } return View(model); } [HttpPost("story-intelligence/knowledge")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceKnowledge(StoryIntelligenceKnowledgeImportForm form) { var (progress, result) = await storyIntelligence.ImportKnowledgeAsync(form.BatchID, form); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID }); } [HttpGet("story-intelligence/narrative")] public async Task StoryIntelligenceNarrative(Guid batchId) { var model = await storyIntelligence.GetNarrativeReviewAsync(batchId); if (model is null) { return NotFound(); } if (model.HasActiveRuns) { return RedirectToBook(model); } if (!model.SceneAnalysisComplete) { return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } if (!model.CharacterGateSatisfied) { return RedirectToReviewCentreWithCharacterGate(batchId); } return View(model); } [HttpPost("story-intelligence/narrative")] [ValidateAntiForgeryToken] public async Task ImportStoryIntelligenceNarrative(StoryIntelligenceNarrativeImportForm form) { var (progress, result) = await storyIntelligence.ImportNarrativeAsync(form.BatchID, form); if (progress is null) { return NotFound(); } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) : RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = form.BatchID }); } [HttpPost("story-intelligence/plot-synthesis/retry")] public async Task RetryStoryIntelligencePlotSynthesis(Guid batchId) { var model = await storyIntelligence.GetReviewCentreAsync(batchId); if (model is null) { return NotFound(); } if (!currentUser.UserId.HasValue) { return Forbid(); } await wholeBookPlotIntelligence.RetryAsync(model.BookID, currentUser.UserId.Value); TempData["OnboardingStoryIntelligenceMessage"] = "Plot Lines analysis has been queued again."; return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model) => RedirectToAction("Details", "Books", new { id = model.BookID }); private IActionResult RedirectToBook(StoryIntelligenceOverviewViewModel model) => RedirectToAction("Details", "Books", new { id = model.BookID }); private IActionResult RedirectToReviewCentreWithCharacterGate(Guid batchId) { TempData["OnboardingStoryIntelligenceMessage"] = "Resolve Characters before reviewing this category."; return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } [HttpPost("scan-review")] [RequestSizeLimit(25 * 1024 * 1024)] [RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)] [ValidateAntiForgeryToken] public async Task SaveScanReview(ManuscriptScanReviewForm form, string intent = "save", int characterPage = 1) { var continueToCharacters = string.Equals(intent, "characters", StringComparison.OrdinalIgnoreCase); var continueCharacterReview = string.Equals(intent, "character-next", StringComparison.OrdinalIgnoreCase); var backCharacterReview = string.Equals(intent, "character-back", StringComparison.OrdinalIgnoreCase); var readyToImport = string.Equals(intent, "structure", StringComparison.OrdinalIgnoreCase); var completeCoreImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase); var nextCharacterPage = Math.Max(1, characterPage + (backCharacterReview ? -1 : 1)); try { if (completeCoreImport) { var result = await onboarding.FinaliseCharacterReviewAsync(form); if (result is null) { return NotFound(); } TempData["OnboardingReviewMessage"] = "Characters saved. Core manuscript import is complete."; return RedirectToAction(nameof(BuildComplete), new { previewId = form.PreviewID }); } var model = await onboarding.SaveScanReviewAsync(form, readyToImport); if (model is null) { return NotFound(); } if (continueToCharacters) { var build = await onboarding.GetBuildResultAsync(form.PreviewID); if (build is null || build.SceneMappings.Count == 0) { TempData["ArchiveError"] = "Import the approved chapters and scenes before reviewing characters."; await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); return RedirectToAction(nameof(Index)); } if (!await onboarding.IsBasicLocationReviewCompleteAsync(form.PreviewID)) { TempData["OnboardingReviewMessage"] = "Review or skip basic locations before reviewing characters."; return RedirectToAction(nameof(ScanLocations), new { previewId = form.PreviewID }); } } TempData["OnboardingReviewMessage"] = readyToImport ? "Chapter review saved. Next: import scenes and update Word." : continueToCharacters ? "Chapter and scene review saved. Next: review detected characters." : continueCharacterReview || backCharacterReview ? "Character review choices saved." : "Review choices saved."; return readyToImport ? RedirectToAction(nameof(Index)) : continueCharacterReview || backCharacterReview ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID, page = nextCharacterPage }) : continueToCharacters ? RedirectToAction(nameof(ScanLocations), new { previewId = form.PreviewID }) : RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID }); } catch (InvalidOperationException ex) { ModelState.AddModelError(string.Empty, ex.Message); var model = await onboarding.GetScanReviewAsync(form.PreviewID); if (model is null) { return NotFound(); } if (continueToCharacters || continueCharacterReview || backCharacterReview || completeCoreImport) { ViewData["ScanReviewStep"] = "characters"; ViewData["CharacterReviewPage"] = readyToImport ? FirstInvalidCharacterPage(model) : Math.Max(1, characterPage); } return View("ScanReview", model); } } private static int FirstInvalidCharacterPage(ManuscriptScanReviewViewModel model) { const int pageSize = 10; var characters = model.CharacterCandidates .Where(character => !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)) .OrderBy(character => character.ReviewName) .ThenBy(character => character.Name) .ToList(); var canonicalNames = characters .Where(IsIncludedCanonicalCharacter) .Select(character => character.ReviewName.Trim()) .Where(name => !string.IsNullOrWhiteSpace(name)) .GroupBy(name => name, StringComparer.OrdinalIgnoreCase) .Where(group => group.Count() > 1) .SelectMany(group => group) .ToHashSet(StringComparer.OrdinalIgnoreCase); for (var index = 0; index < characters.Count; index++) { var character = characters[index]; if (character.Include && string.IsNullOrWhiteSpace(character.ReviewName)) { return (index / pageSize) + 1; } if (IsIncludedCanonicalCharacter(character) && canonicalNames.Contains(character.ReviewName.Trim())) { return (index / pageSize) + 1; } if (character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)) { var target = characters.FirstOrDefault(candidate => string.Equals(candidate.TemporaryCharacterKey, character.AliasTargetTemporaryCharacterKey, StringComparison.Ordinal)); if (target is null || !IsIncludedCanonicalCharacter(target)) { return (index / pageSize) + 1; } } } return 1; } private static bool IsIncludedCanonicalCharacter(ManuscriptScanReviewCharacterViewModel character) => character.Include && string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey) && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase); [HttpPost("welcome")] [ValidateAntiForgeryToken] public async Task Welcome() { await onboarding.MoveToNextStepAsync(); return RedirectToAction(nameof(Index)); } [HttpPost("writing-journey")] [ValidateAntiForgeryToken] public async Task WritingJourney(WritingJourneyOnboardingForm form) { if (!ModelState.IsValid || string.IsNullOrWhiteSpace(form.WritingJourney)) { var model = await onboarding.GetWizardAsync(); model.CurrentStep = OnboardingSteps.WritingJourney; model.StepNumber = 2; return View(nameof(Index), model); } await onboarding.SaveWritingJourneyAsync(form.WritingJourney); return RedirectToAction(nameof(Index)); } [HttpPost("writing-software")] [ValidateAntiForgeryToken] public async Task WritingSoftware(WritingSoftwareOnboardingForm form) { if (!ModelState.IsValid || string.IsNullOrWhiteSpace(form.WritingSoftware)) { var model = await onboarding.GetWizardAsync(); model.CurrentStep = OnboardingSteps.WritingSoftware; model.StepNumber = 3; return View(nameof(Index), model); } await onboarding.SaveWritingSoftwareAsync(form.WritingSoftware); return RedirectToAction(nameof(Index)); } [HttpPost("project")] [ValidateAntiForgeryToken] public async Task Project(ProjectOnboardingForm form) { if (string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal) && string.IsNullOrWhiteSpace(form.ProjectName)) { ModelState.AddModelError(nameof(form.ProjectName), "Enter a project name."); } if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal) && !form.SelectedProjectID.HasValue) { ModelState.AddModelError(nameof(form.SelectedProjectID), "Choose one of your active projects."); } if (!ModelState.IsValid) { var invalidModel = await onboarding.GetWizardAsync(); invalidModel.CurrentStep = OnboardingSteps.Project; invalidModel.StepNumber = 4; invalidModel.ProjectForm = form; return View(nameof(Index), invalidModel); } try { await onboarding.SaveProjectAsync(form); } catch (InvalidOperationException ex) { ModelState.AddModelError(string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal) ? nameof(form.SelectedProjectID) : nameof(form.ProjectName), ex.Message); var invalidModel = await onboarding.GetWizardAsync(); invalidModel.CurrentStep = OnboardingSteps.Project; invalidModel.StepNumber = 4; invalidModel.ProjectForm = form; return View(nameof(Index), invalidModel); } return RedirectToAction(nameof(Index)); } [HttpPost("book")] [ValidateAntiForgeryToken] public async Task Book(BookOnboardingForm form) { if (string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal) && string.IsNullOrWhiteSpace(form.BookTitle)) { ModelState.AddModelError(nameof(form.BookTitle), "Enter a book title."); } if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal) && !form.SelectedBookID.HasValue) { ModelState.AddModelError(nameof(form.SelectedBookID), "Choose a book from the selected project."); } if (!ModelState.IsValid) { var invalidModel = await onboarding.GetWizardAsync(); invalidModel.CurrentStep = OnboardingSteps.Book; invalidModel.StepNumber = 5; invalidModel.BookForm = form; return View(nameof(Index), invalidModel); } try { await onboarding.SaveBookAsync(form); } catch (SubscriptionLimitException ex) { ModelState.AddModelError(nameof(form.BookTitle), ex.Message); var invalidModel = await onboarding.GetWizardAsync(); invalidModel.CurrentStep = OnboardingSteps.Book; invalidModel.StepNumber = 5; invalidModel.BookForm = form; return View(nameof(Index), invalidModel); } catch (InvalidOperationException ex) { ModelState.AddModelError(string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal) ? nameof(form.SelectedBookID) : nameof(form.BookTitle), ex.Message); var invalidModel = await onboarding.GetWizardAsync(); invalidModel.CurrentStep = OnboardingSteps.Book; invalidModel.StepNumber = 5; invalidModel.BookForm = form; return View(nameof(Index), invalidModel); } return RedirectToAction(nameof(Index)); } [HttpPost("finish")] [ValidateAntiForgeryToken] public async Task Finish() { await onboarding.MarkCompletedAsync(); TempData["ArchiveMessage"] = "Setup saved. You can return to setup preferences from your account menu."; return RedirectToAction("Index", "Projects"); } [HttpPost("back")] [ValidateAntiForgeryToken] public async Task Back(string currentStep) { var targetStep = currentStep switch { OnboardingSteps.WritingJourney => OnboardingSteps.Welcome, OnboardingSteps.WritingSoftware => OnboardingSteps.WritingJourney, OnboardingSteps.Project => OnboardingSteps.WritingSoftware, OnboardingSteps.Book => OnboardingSteps.Project, OnboardingSteps.NextPathPreview => OnboardingSteps.Book, _ => OnboardingSteps.Welcome }; await onboarding.SetCurrentStepAsync(targetStep); return RedirectToAction(nameof(Index)); } }