Simplify import wizard and add book context

This commit is contained in:
Nick Beckley 2026-08-27 16:02:47 +00:00
parent 358913f0ef
commit dabf63d91d
20 changed files with 575 additions and 79 deletions

View File

@ -92,6 +92,10 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes),
("Core import scene titles ride existing structural AI call", CoreImportSceneTitlesRideExistingStructuralAiCall),
("Core import scene title preference is review scoped", CoreImportSceneTitlePreferenceIsReviewScoped),
("Core import scene title preference moved to Detect Scenes", CoreImportSceneTitlePreferenceMovedToDetectScenes),
("Book context is canonical import metadata", BookContextIsCanonicalImportMetadata),
("Names defaults from selected book context", NamesDefaultsFromSelectedBookContext),
("Story Intelligence starts from core import complete", StoryIntelligenceStartsFromCoreImportComplete),
("Generated scene titles persist with provenance", GeneratedSceneTitlesPersistWithProvenance),
("Scene title display keeps scene numbers visible", SceneTitleDisplayKeepsSceneNumbersVisible),
("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
@ -2537,14 +2541,74 @@ static void CoreImportSceneTitlePreferenceIsReviewScoped()
var models = File.ReadAllText(Path.Combine(root, "Models/ManuscriptScanModels.cs"));
var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var review = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml"));
Assert(models.Contains("public bool GenerateSceneTitles", StringComparison.Ordinal), "Saved scan review should persist the scene-title generation choice.");
Assert(viewModels.Contains("public bool GenerateSceneTitles", StringComparison.Ordinal), "Scan review form/view model should expose the scene-title option.");
Assert(review.Contains("Generate descriptive scene titles", StringComparison.Ordinal), "Chapter review should show the scene-title checkbox before detection.");
Assert(onboarding.Contains("Title = generateSceneTitles ? scene.SuggestedTitle ?? string.Empty : string.Empty", StringComparison.Ordinal), "Unchecked scene title generation should suppress persisted generated titles.");
}
static void CoreImportSceneTitlePreferenceMovedToDetectScenes()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var review = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml"));
var index = File.ReadAllText(Path.Combine(root, "Views/Onboarding/Index.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js"));
var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs"));
Assert(!review.Contains("Generate descriptive scene titles", StringComparison.Ordinal), "Chapter review should not show the scene-title checkbox.");
Assert(index.Contains("data-onboarding-generate-scene-titles", StringComparison.Ordinal), "Detect Scenes should own the scene-title checkbox.");
Assert(script.Contains("StartOnboardingProjectBuild\", previewId, generateSceneTitles", StringComparison.Ordinal), "Detect Scenes should send the title preference when starting the build.");
Assert(hub.Contains("StartOnboardingProjectBuild(Guid previewId, bool generateSceneTitles = true)", StringComparison.Ordinal), "Build hub should accept the title preference without a separate AI call.");
}
static void BookContextIsCanonicalImportMetadata()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var model = File.ReadAllText(Path.Combine(root, "Models/CoreModels.cs"));
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs"));
var edit = File.ReadAllText(Path.Combine(root, "Views/Books/Edit.cshtml"));
var onboarding = File.ReadAllText(Path.Combine(root, "Views/Onboarding/Index.cshtml"));
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/163_Phase25H_BookContextAndLeanImportFlow.sql"));
Assert(model.Contains("public string? StoryEra", StringComparison.Ordinal), "Book should carry story era as central metadata.");
Assert(model.Contains("public string? PrimaryLocaleCode", StringComparison.Ordinal), "Book should carry primary locale as central metadata.");
Assert(viewModel.Contains("StoryEraOptions", StringComparison.Ordinal) && viewModel.Contains("PrimaryLocaleOptions", StringComparison.Ordinal), "Book edit view model should expose era and locale options.");
Assert(edit.Contains("asp-for=\"StoryEra\"", StringComparison.Ordinal) && edit.Contains("asp-for=\"PrimaryLocaleCode\"", StringComparison.Ordinal), "Book create/edit should expose era and primary locale.");
Assert(onboarding.Contains("name=\"StoryEra\"", StringComparison.Ordinal) && onboarding.Contains("name=\"PrimaryLocaleCode\"", StringComparison.Ordinal), "Onboarding book creation should capture era and primary locale.");
Assert(repository.Contains("book.StoryEra", StringComparison.Ordinal) && repository.Contains("book.PrimaryLocaleCode", StringComparison.Ordinal), "Book repository should pass context through stored procedures.");
Assert(migration.Contains("ALTER TABLE dbo.Books ADD StoryEra", StringComparison.Ordinal), "Book context migration should add StoryEra.");
Assert(migration.Contains("ALTER TABLE dbo.Books ADD PrimaryLocaleCode", StringComparison.Ordinal), "Book context migration should add PrimaryLocaleCode.");
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Book_Save", StringComparison.Ordinal), "Book context migration should update Book_Save.");
}
static void NamesDefaultsFromSelectedBookContext()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/NameIntelligenceService.cs"));
Assert(service.Contains("ApplyBookDefaults(filter, projectBooks, locales)", StringComparison.Ordinal), "Names library should apply selected-book defaults before searching.");
Assert(service.Contains("filter.LocaleCode = book.PrimaryLocaleCode", StringComparison.Ordinal), "Names library should default locale from Book.PrimaryLocaleCode.");
Assert(service.Contains("BookContextOptions.RepresentativeBirthYear(book.StoryEra)", StringComparison.Ordinal), "Names library should default era as a representative birth year.");
}
static void StoryIntelligenceStartsFromCoreImportComplete()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var complete = File.ReadAllText(Path.Combine(root, "Views/Onboarding/BuildComplete.cshtml"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
Assert(complete.Contains("asp-action=\"StartStoryIntelligence\"", StringComparison.Ordinal), "Core Import Complete should start Story Intelligence directly.");
Assert(!complete.Contains("asp-action=\"StoryIntelligence\"", StringComparison.Ordinal), "Core Import Complete should not route through the redundant ready page.");
Assert(controller.Contains("public async Task<IActionResult> StoryIntelligence()\n {", StringComparison.Ordinal)
&& controller.Contains("return RedirectToAction(nameof(BuildComplete)", StringComparison.Ordinal), "Old ready route should be a compatibility redirect.");
Assert(controller.Contains("public async Task<IActionResult> StoryIntelligenceContext()\n {\n return await StoryIntelligence();", StringComparison.Ordinal), "Old context route should not remain an active wizard page.");
Assert(controller.Contains("var job = await storyIntelligence.StartAsync();", StringComparison.Ordinal), "Story Intelligence start should not require portrait/context form fields.");
Assert(service.Contains("BookContextOptions.StoryEraLabel(book?.StoryEra)", StringComparison.Ordinal), "Story Intelligence should derive era from Book context.");
Assert(service.Contains("BookContextOptions.PrimaryLocaleLabel(book?.PrimaryLocaleCode)", StringComparison.Ordinal), "Story Intelligence should derive locale from Book context.");
}
static void GeneratedSceneTitlesPersistWithProvenance()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -69,6 +69,8 @@ public sealed class BooksController(IBookService books, IOnboardingService onboa
createModel.BookNumber = model.BookNumber;
createModel.Description = model.Description;
createModel.TargetWordCount = model.TargetWordCount;
createModel.StoryEra = model.StoryEra;
createModel.PrimaryLocaleCode = model.PrimaryLocaleCode;
createModel.WritingStartDate = model.WritingStartDate;
createModel.TargetCompletionDate = model.TargetCompletionDate;
createModel.PublicationDate = model.PublicationDate;

View File

@ -84,14 +84,23 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
public async Task<IActionResult> StoryIntelligence()
{
var model = await storyIntelligence.GetOverviewAsync();
return View(model);
if (model.ExistingBatchID.HasValue)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = model.ExistingBatchID.Value });
}
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()
{
var model = await storyIntelligence.GetOverviewAsync();
return View(model);
return await StoryIntelligence();
}
[HttpGet("story-intelligence/book/{bookId:int}/continue")]
@ -119,17 +128,13 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
[HttpPost("story-intelligence/start")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartStoryIntelligence(
[FromForm] string? appearancePreference,
[FromForm] string? primaryAgeGroup,
[FromForm] string? storyEra,
[FromForm] string? storyLocation)
public async Task<IActionResult> StartStoryIntelligence()
{
var job = await storyIntelligence.StartAsync(appearancePreference, primaryAgeGroup, storyEra, storyLocation);
var job = await storyIntelligence.StartAsync();
if (job is null)
{
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
return RedirectToAction(nameof(StoryIntelligenceContext));
return RedirectToAction(nameof(Index));
}
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID });

View File

@ -2352,6 +2352,8 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB
book.BookNumber,
book.Description,
book.TargetWordCount,
book.StoryEra,
book.PrimaryLocaleCode,
book.WritingStartDate,
book.TargetCompletionDate,
book.PublicationDate,

View File

@ -203,7 +203,7 @@ public sealed class WordCompanionFollowHub(
return scanState;
}
public async Task<OnboardingManuscriptBuildResult> StartOnboardingProjectBuild(Guid previewId)
public async Task<OnboardingManuscriptBuildResult> StartOnboardingProjectBuild(Guid previewId, bool generateSceneTitles = true)
{
var userId = RequireUserId();
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
@ -219,6 +219,7 @@ public sealed class WordCompanionFollowHub(
{
result = await onboardingService.BuildApprovedStructureAsync(
previewId,
generateSceneTitles,
progress => Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", progress));
}
catch (InvalidOperationException ex)

View File

@ -235,6 +235,8 @@ public sealed class Book
public int BookNumber { get; set; }
public string? Description { get; set; }
public int? TargetWordCount { get; set; }
public string? StoryEra { get; set; }
public string? PrimaryLocaleCode { get; set; }
public DateTime? WritingStartDate { get; set; }
public DateTime? TargetCompletionDate { get; set; }
public DateTime? PublicationDate { get; set; }
@ -255,6 +257,53 @@ public sealed class Book
: null;
}
public sealed record BookContextOption(string Value, string Label, int? RepresentativeBirthYear = null);
public static class BookContextOptions
{
public const string ContemporaryEra = "contemporary";
public static readonly IReadOnlyList<BookContextOption> StoryEras =
[
new("historical", "Historical"),
new("1900s", "1900s", 1905),
new("1910s", "1910s", 1915),
new("1920s", "1920s", 1925),
new("1930s", "1930s", 1935),
new("1940s", "1940s", 1945),
new("1950s", "1950s", 1955),
new("1960s", "1960s", 1965),
new("1970s", "1970s", 1975),
new("1980s", "1980s", 1985),
new("1990s", "1990s", 1995),
new("2000s", "2000s", 2005),
new("2010s", "2010s", 2015),
new("2020s", "2020s", 2025),
new(ContemporaryEra, "Contemporary"),
new("future", "Future"),
new("mixed-other", "Mixed / other")
];
public static readonly IReadOnlyList<BookContextOption> PrimaryLocales =
[
new("en-GB", "United Kingdom"),
new("en-IE", "Ireland"),
new("en-US", "United States"),
new("en-CA", "Canada"),
new("en-AU", "Australia"),
new("en-NZ", "New Zealand")
];
public static int? RepresentativeBirthYear(string? storyEra)
=> StoryEras.FirstOrDefault(option => string.Equals(option.Value, storyEra, StringComparison.OrdinalIgnoreCase))?.RepresentativeBirthYear;
public static string? StoryEraLabel(string? storyEra)
=> StoryEras.FirstOrDefault(option => string.Equals(option.Value, storyEra, StringComparison.OrdinalIgnoreCase))?.Label;
public static string? PrimaryLocaleLabel(string? localeCode)
=> PrimaryLocales.FirstOrDefault(option => string.Equals(option.Value, localeCode, StringComparison.OrdinalIgnoreCase))?.Label;
}
public sealed class ManuscriptDocumentModel
{
public int ManuscriptDocumentID { get; set; }

View File

@ -1212,7 +1212,9 @@ public sealed class BookService(
BookNumber = existing.Count + 1,
Status = BookStatuses.Planning,
CoverThumbnailPath = BookCoverService.PlaceholderPath,
StatusOptions = BuildStatusOptions(BookStatuses.Planning)
StatusOptions = BuildStatusOptions(BookStatuses.Planning),
StoryEraOptions = BuildStoryEraOptions(null),
PrimaryLocaleOptions = BuildPrimaryLocaleOptions(null)
};
}
@ -1236,6 +1238,8 @@ public sealed class BookService(
BookNumber = book.BookNumber,
Description = book.Description,
TargetWordCount = book.TargetWordCount,
StoryEra = book.StoryEra,
PrimaryLocaleCode = book.PrimaryLocaleCode,
WritingStartDate = book.WritingStartDate,
TargetCompletionDate = book.TargetCompletionDate,
PublicationDate = book.PublicationDate,
@ -1243,7 +1247,9 @@ public sealed class BookService(
CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath,
CoverStandardPath = book.CoverStandardPath,
Project = await projects.GetAsync(book.ProjectID),
StatusOptions = BuildStatusOptions(book.Status)
StatusOptions = BuildStatusOptions(book.Status),
StoryEraOptions = BuildStoryEraOptions(book.StoryEra),
PrimaryLocaleOptions = BuildPrimaryLocaleOptions(book.PrimaryLocaleCode)
};
}
@ -1251,6 +1257,8 @@ public sealed class BookService(
{
model.Project = await projects.GetAsync(model.ProjectID);
model.StatusOptions = BuildStatusOptions(model.Status);
model.StoryEraOptions = BuildStoryEraOptions(model.StoryEra);
model.PrimaryLocaleOptions = BuildPrimaryLocaleOptions(model.PrimaryLocaleCode);
if (model.BookID != 0)
{
var book = await books.GetAsync(model.BookID);
@ -1284,6 +1292,8 @@ public sealed class BookService(
BookNumber = model.BookNumber,
Description = model.Description,
TargetWordCount = model.TargetWordCount,
StoryEra = TrimOptional(model.StoryEra),
PrimaryLocaleCode = TrimOptional(model.PrimaryLocaleCode),
WritingStartDate = model.WritingStartDate,
TargetCompletionDate = model.TargetCompletionDate,
PublicationDate = model.PublicationDate,
@ -1309,6 +1319,31 @@ public sealed class BookService(
return new(bookId, true);
}
private static string? TrimOptional(string? value)
{
var clean = value?.Trim();
return string.IsNullOrWhiteSpace(clean) ? null : clean;
}
private static IReadOnlyList<SelectListItem> BuildStoryEraOptions(string? selected)
=> BuildContextOptions(BookContextOptions.StoryEras, selected, "Not set");
private static IReadOnlyList<SelectListItem> BuildPrimaryLocaleOptions(string? selected)
=> BuildContextOptions(BookContextOptions.PrimaryLocales, selected, "Not set");
private static IReadOnlyList<SelectListItem> BuildContextOptions(IReadOnlyList<BookContextOption> options, string? selected, string emptyLabel)
{
var items = new List<SelectListItem>
{
new(emptyLabel, string.Empty, string.IsNullOrWhiteSpace(selected))
};
items.AddRange(options.Select(option => new SelectListItem(
option.Label,
option.Value,
string.Equals(option.Value, selected, StringComparison.OrdinalIgnoreCase))));
return items;
}
public Task<ManuscriptDocumentUnlinkResult?> UnlinkManuscriptAsync(int bookId)
{
return currentUser.UserId is int userId

View File

@ -202,7 +202,9 @@ public sealed class ManuscriptStructureAnalysisService(
bookId = request.BookID,
chapterId = (int?)null,
chapterNumber = request.ChapterNumber,
sourceLabel = request.SourceLabel
sourceLabel = request.SourceLabel,
storyEra = request.StoryEra,
primaryLocale = request.PrimaryLocale
}, JsonOptions);
private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText)
@ -452,6 +454,8 @@ public sealed class ManuscriptStructureAnalysisRequest
public string TemporaryChapterKey { get; init; } = string.Empty;
public int ChapterNumber { get; init; }
public string SourceLabel { get; init; } = string.Empty;
public string? StoryEra { get; init; }
public string? PrimaryLocale { get; init; }
public string ChapterText { get; init; } = string.Empty;
public IReadOnlyList<ManuscriptScanParagraphMap> ParagraphMap { get; init; } = [];
public IReadOnlyList<ManuscriptScanSourceParagraph> SourceParagraphs { get; init; } = [];

View File

@ -134,17 +134,45 @@ public sealed class NameIntelligenceService(
}
var projectBooks = await books.ListByProjectAsync(filter.ProjectID);
var locales = await ListLocalesAsync(cancellationToken);
ApplyBookDefaults(filter, projectBooks, locales);
var rows = await SearchAsync(filter, cancellationToken);
return new NamesLibraryViewModel
{
Project = project,
Filter = filter,
Locales = await ListLocalesAsync(cancellationToken),
Locales = locales,
Books = projectBooks,
Results = rows
};
}
private static void ApplyBookDefaults(NameSearchFilter filter, IReadOnlyList<Book> projectBooks, IReadOnlyList<NameLocale> locales)
{
if (!filter.BookID.HasValue)
{
return;
}
var book = projectBooks.FirstOrDefault(item => item.BookID == filter.BookID.Value);
if (book is null)
{
return;
}
if (string.IsNullOrWhiteSpace(filter.LocaleCode)
&& !string.IsNullOrWhiteSpace(book.PrimaryLocaleCode)
&& locales.Any(locale => string.Equals(locale.LocaleCode, book.PrimaryLocaleCode, StringComparison.OrdinalIgnoreCase)))
{
filter.LocaleCode = book.PrimaryLocaleCode;
}
if (!filter.BirthYear.HasValue)
{
filter.BirthYear = BookContextOptions.RepresentativeBirthYear(book.StoryEra);
}
}
public static string NormaliseName(string? value)
=> string.IsNullOrWhiteSpace(value)
? string.Empty

View File

@ -12,7 +12,7 @@ public interface IOnboardingService
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
Task<OnboardingManuscriptBuildResult?> FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form);
Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null);
Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func<OnboardingBuildProgress, Task>? progress = null);
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId);
Task<bool> ShouldShowOnboardingAsync();
Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
@ -103,7 +103,9 @@ public sealed class OnboardingService(
BookForm = new BookOnboardingForm
{
Mode = bookOptions.Count == 0 || !state.BookID.HasValue ? OnboardingSelectionModes.Create : OnboardingSelectionModes.Select,
SelectedBookID = state.BookID
SelectedBookID = state.BookID,
StoryEra = selectedBook?.StoryEra,
PrimaryLocaleCode = selectedBook?.PrimaryLocaleCode
},
Options = step switch
{
@ -225,7 +227,7 @@ public sealed class OnboardingService(
return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters, build));
}
public async Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null)
public async Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func<OnboardingBuildProgress, Task>? progress = null)
{
var context = await GetScanReviewContextAsync(previewId);
if (context is null)
@ -233,13 +235,26 @@ public sealed class OnboardingService(
return null;
}
var (userId, preview, review, _, _, _, _) = context.Value;
var generateSceneTitles = review.GenerateSceneTitles;
var (userId, preview, review, _, book, _, _) = context.Value;
if (!string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
{
throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters.");
}
if (review.GenerateSceneTitles != generateSceneTitles)
{
review = new ManuscriptScanReviewDecision
{
PreviewID = review.PreviewID,
Status = review.Status,
GenerateSceneTitles = generateSceneTitles,
Chapters = review.Chapters,
Scenes = review.Scenes,
Characters = review.Characters
};
await scanPreviews.SaveReviewAsync(userId, review);
}
var existingBuild = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID)
?? await builds.GetByPreviewAsync(userId, preview.PreviewID);
if (existingBuild is not null && existingBuild.SceneMappings.Count > 0)
@ -287,6 +302,8 @@ public sealed class OnboardingService(
TemporaryChapterKey = chapterDecision.TemporaryChapterKey,
ChapterNumber = chapterDecision.ChapterNumber,
SourceLabel = $"{preview.DocumentTitle ?? "Word manuscript"}: {chapterDecision.Title}",
StoryEra = BookContextOptions.StoryEraLabel(book.StoryEra),
PrimaryLocale = BookContextOptions.PrimaryLocaleLabel(book.PrimaryLocaleCode),
ChapterText = chapterPreview.ChapterText,
ParagraphMap = chapterPreview.ParagraphMap,
SourceParagraphs = chapterPreview.SourceParagraphs
@ -1255,6 +1272,8 @@ public sealed class OnboardingService(
createModel.BookTitle = bookTitle;
createModel.Subtitle = CleanOptional(form.Subtitle);
createModel.TargetWordCount = form.TargetWordCount;
createModel.StoryEra = CleanOptional(form.StoryEra);
createModel.PrimaryLocaleCode = CleanOptional(form.PrimaryLocaleCode);
var result = await bookService.SaveAsync(createModel);
if (!result.Succeeded)
{

View File

@ -45,6 +45,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceSourceRepository sources,
IStoryIntelligencePipelineStateService pipelineState,
IStoryMemoryRepository storyMemory,
IBookRepository books,
IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore,
IStoryIntelligenceProgressNotifier notifier,
@ -106,6 +107,7 @@ public sealed class OnboardingStoryIntelligenceService(
}
var (state, preview, review, build, wizard) = context.Value;
var book = await books.GetAsync(preview.BookID);
var existing = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
if (existing is not null)
{
@ -215,8 +217,12 @@ public sealed class OnboardingStoryIntelligenceService(
BookTitle = wizard.SelectedBookTitle,
AppearancePreference = NormaliseAppearancePreference(appearancePreference),
PrimaryAgeGroup = CleanContext(primaryAgeGroup),
StoryEra = CleanContext(storyEra),
StoryLocation = CleanContext(storyLocation),
StoryEra = CleanContext(storyEra) is { Length: > 0 } suppliedEra
? suppliedEra
: CleanContext(BookContextOptions.StoryEraLabel(book?.StoryEra)),
StoryLocation = CleanContext(storyLocation) is { Length: > 0 } suppliedLocation
? suppliedLocation
: CleanContext(BookContextOptions.PrimaryLocaleLabel(book?.PrimaryLocaleCode)),
Items = batchItems
};

View File

@ -0,0 +1,198 @@
SET NOCOUNT ON;
GO
IF COL_LENGTH('dbo.Books', 'StoryEra') IS NULL
BEGIN
ALTER TABLE dbo.Books ADD StoryEra nvarchar(80) NULL;
END;
GO
IF COL_LENGTH('dbo.Books', 'PrimaryLocaleCode') IS NULL
BEGIN
ALTER TABLE dbo.Books ADD PrimaryLocaleCode nvarchar(16) NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Book_ListByProject
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
SELECT b.BookID, b.ProjectID, b.BookTitle, b.Subtitle, b.Tagline, b.ShortDescription, b.Status,
b.BookNumber, b.Description, b.TargetWordCount, b.StoryEra, b.PrimaryLocaleCode,
b.WritingStartDate, b.TargetCompletionDate, b.PublicationDate, b.UsesExplicitScenes,
b.CoverThumbnailPath, b.CoverStandardPath,
ISNULL(wordCounts.CurrentWordCount, 0) AS CurrentWordCount,
ISNULL(counts.ChapterCount, 0) AS ChapterCount,
ISNULL(counts.SceneCount, 0) AS SceneCount,
b.SortOrder, b.CreatedDate, b.UpdatedDate, b.IsArchived, b.ArchivedDate, b.ArchivedReason
FROM dbo.Books b
OUTER APPLY
(
SELECT COUNT(DISTINCT c.ChapterID) AS ChapterCount, COUNT(s.SceneID) AS SceneCount
FROM dbo.Chapters c
LEFT JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) counts
OUTER APPLY
(
SELECT SUM(ISNULL(sw.ActualWordCount, 0)) AS CurrentWordCount
FROM dbo.Chapters c
INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) wordCounts
WHERE b.ProjectID = @ProjectID AND b.IsArchived = 0
ORDER BY b.SortOrder, b.BookNumber, b.BookTitle;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Book_ListByProjects
@ProjectIDs nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
;WITH ProjectIDs AS
(
SELECT DISTINCT TRY_CONVERT(int, value) AS ProjectID
FROM STRING_SPLIT(COALESCE(@ProjectIDs, N''), N',')
WHERE TRY_CONVERT(int, value) IS NOT NULL
)
SELECT b.BookID, b.ProjectID, b.BookTitle, b.Subtitle, b.Tagline, b.ShortDescription, b.Status,
b.BookNumber, b.Description, b.TargetWordCount, b.StoryEra, b.PrimaryLocaleCode,
b.WritingStartDate, b.TargetCompletionDate, b.PublicationDate, b.UsesExplicitScenes,
b.CoverThumbnailPath, b.CoverStandardPath,
ISNULL(wordCounts.CurrentWordCount, 0) AS CurrentWordCount,
ISNULL(counts.ChapterCount, 0) AS ChapterCount,
ISNULL(counts.SceneCount, 0) AS SceneCount,
b.SortOrder, b.CreatedDate, b.UpdatedDate, b.IsArchived, b.ArchivedDate, b.ArchivedReason
FROM dbo.Books b
INNER JOIN ProjectIDs ids ON ids.ProjectID = b.ProjectID
OUTER APPLY
(
SELECT COUNT(DISTINCT c.ChapterID) AS ChapterCount, COUNT(s.SceneID) AS SceneCount
FROM dbo.Chapters c
LEFT JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) counts
OUTER APPLY
(
SELECT SUM(ISNULL(sw.ActualWordCount, 0)) AS CurrentWordCount
FROM dbo.Chapters c
INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) wordCounts
WHERE b.IsArchived = 0
ORDER BY b.ProjectID, b.SortOrder, b.BookNumber, b.BookTitle;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Book_Get
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT b.BookID, b.ProjectID, b.BookTitle, b.Subtitle, b.Tagline, b.ShortDescription, b.Status,
b.BookNumber, b.Description, b.TargetWordCount, b.StoryEra, b.PrimaryLocaleCode,
b.WritingStartDate, b.TargetCompletionDate, b.PublicationDate, b.UsesExplicitScenes,
b.CoverThumbnailPath, b.CoverStandardPath,
ISNULL(wordCounts.CurrentWordCount, 0) AS CurrentWordCount,
ISNULL(counts.ChapterCount, 0) AS ChapterCount,
ISNULL(counts.SceneCount, 0) AS SceneCount,
b.SortOrder, b.CreatedDate, b.UpdatedDate, b.IsArchived, b.ArchivedDate, b.ArchivedReason
FROM dbo.Books b
OUTER APPLY
(
SELECT COUNT(DISTINCT c.ChapterID) AS ChapterCount, COUNT(s.SceneID) AS SceneCount
FROM dbo.Chapters c
LEFT JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) counts
OUTER APPLY
(
SELECT SUM(ISNULL(sw.ActualWordCount, 0)) AS CurrentWordCount
FROM dbo.Chapters c
INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) wordCounts
WHERE b.BookID = @BookID AND b.IsArchived = 0;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Book_Save
@BookID int = NULL,
@ProjectID int,
@BookTitle nvarchar(200),
@Subtitle nvarchar(200) = NULL,
@Tagline nvarchar(300) = NULL,
@ShortDescription nvarchar(max) = NULL,
@Status nvarchar(50) = N'Planning',
@BookNumber int,
@Description nvarchar(max) = NULL,
@TargetWordCount int = NULL,
@StoryEra nvarchar(80) = NULL,
@PrimaryLocaleCode nvarchar(16) = NULL,
@WritingStartDate date = NULL,
@TargetCompletionDate date = NULL,
@PublicationDate date = NULL,
@UsesExplicitScenes bit = 0
AS
BEGIN
SET NOCOUNT ON;
IF @TargetWordCount IS NOT NULL AND @TargetWordCount <= 0
THROW 51000, 'Target word count must be greater than zero.', 1;
SET @StoryEra = NULLIF(LTRIM(RTRIM(@StoryEra)), N'');
SET @PrimaryLocaleCode = NULLIF(LTRIM(RTRIM(@PrimaryLocaleCode)), N'');
IF NULLIF(LTRIM(RTRIM(@Status)), N'') IS NULL
SET @Status = N'Planning';
IF @BookID IS NULL OR @BookID = 0
BEGIN
DECLARE @NextBookOrder int = ISNULL((SELECT MAX(SortOrder) FROM dbo.Books WHERE ProjectID = @ProjectID), 0) + 10;
INSERT dbo.Books
(
ProjectID, BookTitle, Subtitle, Tagline, ShortDescription, Status, BookNumber, Description,
TargetWordCount, StoryEra, PrimaryLocaleCode, WritingStartDate, TargetCompletionDate,
PublicationDate, UsesExplicitScenes, SortOrder
)
VALUES
(
@ProjectID, @BookTitle, @Subtitle, @Tagline, @ShortDescription, @Status, @BookNumber, @Description,
@TargetWordCount, @StoryEra, @PrimaryLocaleCode, @WritingStartDate, @TargetCompletionDate,
@PublicationDate, @UsesExplicitScenes, @NextBookOrder
);
SELECT CAST(SCOPE_IDENTITY() AS int) AS BookID;
RETURN;
END;
UPDATE dbo.Books
SET BookTitle = @BookTitle,
Subtitle = @Subtitle,
Tagline = @Tagline,
ShortDescription = @ShortDescription,
Status = @Status,
BookNumber = @BookNumber,
Description = @Description,
TargetWordCount = @TargetWordCount,
StoryEra = @StoryEra,
PrimaryLocaleCode = @PrimaryLocaleCode,
WritingStartDate = @WritingStartDate,
TargetCompletionDate = @TargetCompletionDate,
PublicationDate = @PublicationDate,
UsesExplicitScenes = @UsesExplicitScenes,
UpdatedDate = SYSUTCDATETIME()
WHERE BookID = @BookID;
SELECT @BookID AS BookID;
END;
GO

View File

@ -206,6 +206,14 @@ public sealed class BookEditViewModel
[Range(1, int.MaxValue, ErrorMessage = "Target word count must be greater than zero.")]
public int? TargetWordCount { get; set; }
[StringLength(80)]
[Display(Name = "Story era")]
public string? StoryEra { get; set; }
[StringLength(16)]
[Display(Name = "Primary locale")]
public string? PrimaryLocaleCode { get; set; }
[DataType(DataType.Date)]
[Display(Name = "Writing start date")]
public DateTime? WritingStartDate { get; set; }
@ -230,6 +238,8 @@ public sealed class BookEditViewModel
public string? Description { get; set; }
public Project? Project { get; set; }
public IReadOnlyList<SelectListItem> StatusOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> StoryEraOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> PrimaryLocaleOptions { get; set; } = [];
public string BeginMode { get; set; } = BookBeginModes.Manual;
}

View File

@ -91,6 +91,12 @@ public sealed class BookOnboardingForm
[Range(1, int.MaxValue, ErrorMessage = "Target word count must be greater than zero.")]
public int? TargetWordCount { get; set; }
[StringLength(80)]
public string? StoryEra { get; set; }
[StringLength(16)]
public string? PrimaryLocaleCode { get; set; }
}
public sealed class OnboardingProjectOptionViewModel

View File

@ -95,6 +95,16 @@
<input asp-for="TargetWordCount" class="form-control" min="1" />
<span asp-validation-for="TargetWordCount" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="StoryEra" class="form-label"></label>
<select asp-for="StoryEra" asp-items="Model.StoryEraOptions" class="form-select"></select>
<span asp-validation-for="StoryEra" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="PrimaryLocaleCode" class="form-label"></label>
<select asp-for="PrimaryLocaleCode" asp-items="Model.PrimaryLocaleOptions" class="form-select"></select>
<span asp-validation-for="PrimaryLocaleCode" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="WritingStartDate" class="form-label"></label>
<input asp-for="WritingStartDate" class="form-control" />

View File

@ -111,7 +111,9 @@
<div class="onboarding-actions">
@if (coreComplete)
{
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">Analyse my book</a>
<form asp-controller="Onboarding" asp-action="StartStoryIntelligence" method="post">
<button class="btn btn-primary" type="submit">Analyse my book</button>
</form>
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Not now</a>
}
else

View File

@ -207,6 +207,28 @@
<input class="form-control" id="TargetWordCount" name="TargetWordCount" type="number" min="1" value="@Model.BookForm.TargetWordCount" />
@Html.ValidationMessage("TargetWordCount", null, new { @class = "text-danger" })
</div>
<div class="col-md-5">
<label class="form-label" for="StoryEra">Story era</label>
<select class="form-select" id="StoryEra" name="StoryEra">
<option value="" selected="@string.IsNullOrWhiteSpace(Model.BookForm.StoryEra)">Not set</option>
@foreach (var option in BookContextOptions.StoryEras)
{
<option value="@option.Value" selected="@(string.Equals(Model.BookForm.StoryEra, option.Value, StringComparison.OrdinalIgnoreCase))">@option.Label</option>
}
</select>
@Html.ValidationMessage("StoryEra", null, new { @class = "text-danger" })
</div>
<div class="col-md-5">
<label class="form-label" for="PrimaryLocaleCode">Primary locale</label>
<select class="form-select" id="PrimaryLocaleCode" name="PrimaryLocaleCode">
<option value="" selected="@string.IsNullOrWhiteSpace(Model.BookForm.PrimaryLocaleCode)">Not set</option>
@foreach (var option in BookContextOptions.PrimaryLocales)
{
<option value="@option.Value" selected="@(string.Equals(Model.BookForm.PrimaryLocaleCode, option.Value, StringComparison.OrdinalIgnoreCase))">@option.Label</option>
}
</select>
@Html.ValidationMessage("PrimaryLocaleCode", null, new { @class = "text-danger" })
</div>
</div>
</section>
@ -252,6 +274,44 @@
}
</div>
@if (Model.IsMicrosoftWordPath)
{
@if (structureReadyToImport)
{
<section class="onboarding-detect-scenes"
data-word-companion-presence
data-word-companion-status="@Model.CompanionPresence.Status"
aria-label="Scene detection">
<p class="onboarding-compact-status">
<span data-word-companion-waiting-status>@(Model.CompanionPresence.IsConnected ? "Word connected" : "Waiting for Word")</span>
&middot; @Model.ScanState.ChapterCount.ToString("N0") chapter@(Model.ScanState.ChapterCount == 1 ? string.Empty : "s")
&middot; @Model.ScanState.TotalWordCount.ToString("N0") words
</p>
<label class="onboarding-inline-choice">
<input type="checkbox" data-onboarding-generate-scene-titles checked />
<span>
<strong>Generate descriptive scene titles</strong>
<small>Short working titles used only within PlotDirector.</small>
</span>
</label>
<p>Keep Word open until scene markers have been added. If Word cannot save automatically, PlotDirector will ask you to save and confirm.</p>
<div class="onboarding-build-panel" data-onboarding-build-panel>
<div>
<p class="onboarding-scan-next">Ready to detect scenes.</p>
<p class="onboarding-build-message" data-onboarding-build-message>Scene detection has not started yet.</p>
</div>
<strong data-onboarding-build-percent></strong>
<div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true">
<span data-onboarding-build-progress></span>
</div>
<div class="onboarding-manual-save-actions" data-onboarding-manual-save-actions hidden>
<button class="btn btn-primary btn-sm" type="button" data-onboarding-build-save-confirm disabled>
I've saved the Word document
</button>
</div>
</div>
</section>
}
else
{
<section class="onboarding-companion-card onboarding-companion-card--compact @(Model.CompanionPresence.IsConnected ? "is-connected" : "is-offline")"
data-word-companion-presence
@ -306,27 +366,10 @@
<strong data-onboarding-scan-words>@Model.ScanState.TotalWordCount.ToString("N0")</strong>
</div>
</div>
@if (string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
{
<div class="onboarding-build-panel" data-onboarding-build-panel>
<div>
<p class="onboarding-scan-next">Ready to detect scenes.</p>
<p class="onboarding-build-message" data-onboarding-build-message>Keep Word open while markers are written. If Word cannot save automatically, PlotDirector will ask you to save and confirm.</p>
</div>
<strong data-onboarding-build-percent></strong>
<div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true">
<span data-onboarding-build-progress></span>
</div>
<div class="onboarding-manual-save-actions" data-onboarding-manual-save-actions hidden>
<button class="btn btn-primary btn-sm" type="button" data-onboarding-build-save-confirm disabled>
I've saved the Word document
</button>
</div>
</section>
</div>
</section>
}
</section>
</div>
</section>
}
<div class="onboarding-actions">
@if (Model.IsMicrosoftWordPath && reviewedScanAvailable && Model.ScanState.PreviewID.HasValue)

View File

@ -105,14 +105,6 @@
@if (!isCharacterStep)
{
<label class="onboarding-inline-choice">
<input type="checkbox" name="GenerateSceneTitles" value="true" checked="@Model.GenerateSceneTitles" />
<input type="hidden" name="GenerateSceneTitles" value="false" />
<span>
<strong>Generate descriptive scene titles</strong>
<small>Titles are suggested during scene detection and can be edited later. Word markers stay unchanged.</small>
</span>
</label>
<div class="onboarding-review-tools" data-review-tools>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="chapter" data-bulk-state="true">Include all chapters</button>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="chapter" data-bulk-state="false">Exclude all chapters</button>

View File

@ -425,6 +425,25 @@
font-weight: 700;
}
.onboarding-detect-scenes {
display: grid;
gap: .85rem;
margin: 1rem 0 1.25rem;
padding: 1rem;
border: 1px solid rgba(31, 42, 68, .12);
border-radius: 8px;
background: rgba(255, 255, 255, .72);
}
.onboarding-detect-scenes > p:not(.onboarding-compact-status) {
margin: 0;
color: var(--bs-secondary-color);
}
.onboarding-detect-scenes .onboarding-inline-choice {
margin-bottom: 0;
}
.onboarding-build-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;

View File

@ -349,6 +349,7 @@
return;
}
pendingBuildPreviewId = previewId;
const generateSceneTitles = document.querySelector("[data-onboarding-generate-scene-titles]")?.checked !== false;
clearManualSaveRequired();
button.disabled = true;
document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => {
@ -364,7 +365,7 @@
node.style.width = "0%";
});
try {
const result = await connection.invoke("StartOnboardingProjectBuild", previewId);
const result = await connection.invoke("StartOnboardingProjectBuild", previewId, generateSceneTitles);
pendingBuildPreviewId = field(result, "previewID", "PreviewID") || previewId;
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
node.textContent = "Updating Word document with scene markers. Keep Word open, then save the document when prompted.";