PlotDirector/PlotLine/Controllers/OnboardingController.cs

1081 lines
42 KiB
C#

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,
ICharacterEnrichmentService characterEnrichment,
ICurrentUserService currentUser,
ILogger<OnboardingController> logger) : Controller
{
[HttpGet("")]
public async Task<IActionResult> Index()
{
var model = await onboarding.GetWizardAsync();
return View(model);
}
[HttpPost("start-manual")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartManual()
{
await onboarding.StartManualSetupAsync();
return RedirectToAction(nameof(Index));
}
[HttpPost("start-import")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartImport()
{
await onboarding.StartManuscriptImportSetupAsync();
return RedirectToAction(nameof(Index));
}
[HttpGet("scan-review")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> BuildComplete(Guid previewId)
{
var model = await onboarding.GetBuildResultAsync(previewId);
return model is null ? NotFound() : View(model);
}
[HttpGet("story-intelligence")]
public async Task<IActionResult> 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<IActionResult> StoryIntelligenceContext()
{
return await StoryIntelligence();
}
[HttpGet("story-intelligence/book/{bookId:int}/continue")]
public async Task<IActionResult> ContinueStoryIntelligence(int bookId)
=> await StoryIntelligenceBookReviewCentre(bookId);
[HttpGet("story-intelligence/book/{bookId:int}/review-centre")]
public async Task<IActionResult> 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<IActionResult> StartStoryIntelligence()
=> RedirectToAction(nameof(StoryIntelligencePlotContext));
[HttpGet("story-intelligence/plot-context")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> ScanManuscript()
{
await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview);
return RedirectToAction(nameof(Index));
}
[HttpGet("story-intelligence/progress")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> StoryIntelligenceLocations(Guid batchId, int page = 1, string scope = "significant")
{
var model = await storyIntelligence.GetLocationReviewAsync(batchId, page, scope: scope);
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form, string intent = "save")
{
var (progress, result) = await storyIntelligence.ImportLocationsAsync(form.BatchID, form);
if (progress is null)
{
return NotFound();
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
if (!result.Success)
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = Math.Max(1, form.PageNumber), scope = form.Scope });
}
var pendingCount = progress.LocationReview.PendingCandidateCount;
if (pendingCount <= 0)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID });
}
const int pageSize = 25;
var pageCount = Math.Max(1, (int)Math.Ceiling(pendingCount / (double)pageSize));
var currentPage = Math.Max(1, form.PageNumber);
var targetPage = string.Equals(intent, "previous", StringComparison.OrdinalIgnoreCase)
? currentPage - 1
: currentPage;
targetPage = Math.Clamp(targetPage, 1, pageCount);
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = targetPage, scope = form.Scope });
}
[HttpPost("story-intelligence/assets")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form, string intent = "save")
{
var (progress, result) = await storyIntelligence.ImportAssetsAsync(form.BatchID, form);
if (progress is null)
{
return NotFound();
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
if (!result.Success)
{
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID, page = Math.Max(1, form.PageNumber) });
}
var pendingCount = progress.AssetReview.SummaryNeedsAttentionCount ?? progress.AssetReview.PendingCandidateCount;
if (pendingCount <= 0)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID });
}
const int pageSize = 25;
var pageCount = Math.Max(1, (int)Math.Ceiling(pendingCount / (double)pageSize));
var currentPage = Math.Max(1, form.PageNumber);
var targetPage = string.Equals(intent, "previous", StringComparison.OrdinalIgnoreCase)
? currentPage - 1
: currentPage;
targetPage = Math.Clamp(targetPage, 1, pageCount);
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID, page = targetPage });
}
[HttpPost("story-intelligence/relationships")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceRelationships(StoryIntelligenceRelationshipImportForm form)
{
if (!ModelState.IsValid)
{
TempData["OnboardingStoryIntelligenceError"] = "Check relationship values before saving. Intensity must be a whole number from 1 to 10, or blank.";
return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID });
}
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<IActionResult> 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<IActionResult> ImportStoryIntelligenceKnowledge(StoryIntelligenceKnowledgeImportForm form, string intent = "save")
{
var (progress, result) = await storyIntelligence.ImportKnowledgeAsync(form.BatchID, form);
if (progress is null)
{
return NotFound();
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
if (!result.Success)
{
return RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID, page = Math.Max(1, form.PageNumber) });
}
var pendingCount = progress.KnowledgeReview.SummaryNeedsAttentionCount ?? progress.KnowledgeReview.PendingCandidateCount;
if (pendingCount <= 0)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID });
}
const int pageSize = 25;
var pageCount = Math.Max(1, (int)Math.Ceiling(pendingCount / (double)pageSize));
var currentPage = Math.Max(1, form.PageNumber);
var targetPage = string.Equals(intent, "previous", StringComparison.OrdinalIgnoreCase)
? currentPage - 1
: currentPage;
targetPage = Math.Clamp(targetPage, 1, pageCount);
return RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID, page = targetPage });
}
[HttpGet("story-intelligence/narrative")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
[HttpPost("story-intelligence/character-enrichment/retry")]
public async Task<IActionResult> RetryStoryIntelligenceCharacterEnrichment(Guid batchId)
{
var model = await storyIntelligence.GetReviewCentreAsync(batchId);
if (model is null)
{
return NotFound();
}
if (!currentUser.UserId.HasValue)
{
return Forbid();
}
await characterEnrichment.RetryAsync(model.BookID, currentUser.UserId.Value);
TempData["OnboardingStoryIntelligenceMessage"] = "Character enrichment 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<IActionResult> 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<IActionResult> Welcome()
{
await onboarding.MoveToNextStepAsync();
return RedirectToAction(nameof(Index));
}
[HttpPost("writing-journey")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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));
}
}