diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index ffa444c..66c32cb 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -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 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 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"); diff --git a/PlotLine/Controllers/BooksController.cs b/PlotLine/Controllers/BooksController.cs index 1339ecc..a61accf 100644 --- a/PlotLine/Controllers/BooksController.cs +++ b/PlotLine/Controllers/BooksController.cs @@ -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; diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index a14109a..af072b0 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -84,14 +84,23 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard public async Task 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 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 StartStoryIntelligence( - [FromForm] string? appearancePreference, - [FromForm] string? primaryAgeGroup, - [FromForm] string? storyEra, - [FromForm] string? storyLocation) + public async Task 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 }); diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 4f22ff4..20b9736 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -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, diff --git a/PlotLine/Hubs/WordCompanionFollowHub.cs b/PlotLine/Hubs/WordCompanionFollowHub.cs index 03a3007..6575c9c 100644 --- a/PlotLine/Hubs/WordCompanionFollowHub.cs +++ b/PlotLine/Hubs/WordCompanionFollowHub.cs @@ -203,7 +203,7 @@ public sealed class WordCompanionFollowHub( return scanState; } - public async Task StartOnboardingProjectBuild(Guid previewId) + public async Task 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) diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 0214fab..a31ea9a 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -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 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 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; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 5b15f49..41bee1c 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -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 BuildStoryEraOptions(string? selected) + => BuildContextOptions(BookContextOptions.StoryEras, selected, "Not set"); + + private static IReadOnlyList BuildPrimaryLocaleOptions(string? selected) + => BuildContextOptions(BookContextOptions.PrimaryLocales, selected, "Not set"); + + private static IReadOnlyList BuildContextOptions(IReadOnlyList options, string? selected, string emptyLabel) + { + var items = new List + { + 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 UnlinkManuscriptAsync(int bookId) { return currentUser.UserId is int userId diff --git a/PlotLine/Services/ManuscriptStructureAnalysisService.cs b/PlotLine/Services/ManuscriptStructureAnalysisService.cs index 5ea5fec..fa7f676 100644 --- a/PlotLine/Services/ManuscriptStructureAnalysisService.cs +++ b/PlotLine/Services/ManuscriptStructureAnalysisService.cs @@ -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 ParagraphMap { get; init; } = []; public IReadOnlyList SourceParagraphs { get; init; } = []; diff --git a/PlotLine/Services/NameIntelligenceService.cs b/PlotLine/Services/NameIntelligenceService.cs index c1aaf9f..948a89a 100644 --- a/PlotLine/Services/NameIntelligenceService.cs +++ b/PlotLine/Services/NameIntelligenceService.cs @@ -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 projectBooks, IReadOnlyList 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 diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index 6cb81da..3305af8 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -12,7 +12,7 @@ public interface IOnboardingService Task GetScanReviewAsync(Guid? previewId = null); Task SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport); Task FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form); - Task BuildApprovedStructureAsync(Guid previewId, Func? progress = null); + Task BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func? progress = null); Task GetBuildResultAsync(Guid previewId); Task ShouldShowOnboardingAsync(); Task 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 BuildApprovedStructureAsync(Guid previewId, Func? progress = null) + public async Task BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func? 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) { diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index f5a425a..22fe7ee 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -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 }; diff --git a/PlotLine/Sql/163_Phase25H_BookContextAndLeanImportFlow.sql b/PlotLine/Sql/163_Phase25H_BookContextAndLeanImportFlow.sql new file mode 100644 index 0000000..2b80e50 --- /dev/null +++ b/PlotLine/Sql/163_Phase25H_BookContextAndLeanImportFlow.sql @@ -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 diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 49cc600..e4b7e4c 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -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 StatusOptions { get; set; } = []; + public IReadOnlyList StoryEraOptions { get; set; } = []; + public IReadOnlyList PrimaryLocaleOptions { get; set; } = []; public string BeginMode { get; set; } = BookBeginModes.Manual; } diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 619ed3e..93a8eba 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -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 diff --git a/PlotLine/Views/Books/Edit.cshtml b/PlotLine/Views/Books/Edit.cshtml index a37f77b..d11fc85 100644 --- a/PlotLine/Views/Books/Edit.cshtml +++ b/PlotLine/Views/Books/Edit.cshtml @@ -95,6 +95,16 @@ +
+ + + +
+
+ + + +
diff --git a/PlotLine/Views/Onboarding/BuildComplete.cshtml b/PlotLine/Views/Onboarding/BuildComplete.cshtml index eb73667..10e755a 100644 --- a/PlotLine/Views/Onboarding/BuildComplete.cshtml +++ b/PlotLine/Views/Onboarding/BuildComplete.cshtml @@ -111,7 +111,9 @@
@if (coreComplete) { - Analyse my book +
+ +
Not now } else diff --git a/PlotLine/Views/Onboarding/Index.cshtml b/PlotLine/Views/Onboarding/Index.cshtml index d19381e..a26000a 100644 --- a/PlotLine/Views/Onboarding/Index.cshtml +++ b/PlotLine/Views/Onboarding/Index.cshtml @@ -207,6 +207,28 @@ @Html.ValidationMessage("TargetWordCount", null, new { @class = "text-danger" })
+
+ + + @Html.ValidationMessage("StoryEra", null, new { @class = "text-danger" }) +
+
+ + + @Html.ValidationMessage("PrimaryLocaleCode", null, new { @class = "text-danger" }) +
@@ -253,34 +275,72 @@ @if (Model.IsMicrosoftWordPath) { -
- -
-

Word Companion

-

@(Model.CompanionPresence.IsConnected ? "Word Companion Connected" : "Waiting for Word Companion...")

-

- @CompanionScanMessage(Model.CompanionPresence, Model.ScanState) + @if (structureReadyToImport) + { +

+

+ @(Model.CompanionPresence.IsConnected ? "Word connected" : "Waiting for Word") + · @Model.ScanState.ChapterCount.ToString("N0") chapter@(Model.ScanState.ChapterCount == 1 ? string.Empty : "s") + · @Model.ScanState.TotalWordCount.ToString("N0") words

-
+ +

Keep Word open until scene markers have been added. If Word cannot save automatically, PlotDirector will ask you to save and confirm.

+
-
Version
-
@(Model.CompanionPresence.CompanionVersion ?? "Waiting")
+

Ready to detect scenes.

+

Scene detection has not started yet.

-
-
Document
-
@(Model.CompanionPresence.CurrentDocumentName ?? "No document reported yet")
+ + -
-
+ +
+
+ } + else + { +
+ +
+

Word Companion

+

@(Model.CompanionPresence.IsConnected ? "Word Companion Connected" : "Waiting for Word Companion...")

+

+ @CompanionScanMessage(Model.CompanionPresence, Model.ScanState) +

+
+
+
Version
+
@(Model.CompanionPresence.CompanionVersion ?? "Waiting")
+
+
+
Document
+
@(Model.CompanionPresence.CurrentDocumentName ?? "No document reported yet")
+
+
+
@ScanMessage(Model.ScanState) @ScanPercent(Model.ScanState) @@ -306,27 +366,10 @@ @Model.ScanState.TotalWordCount.ToString("N0")
- @if (string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal)) - { -
-
-

Ready to detect scenes.

-

Keep Word open while markers are written. If Word cannot save automatically, PlotDirector will ask you to save and confirm.

-
- - - -
- } -
- - + + + + } }
@if (Model.IsMicrosoftWordPath && reviewedScanAvailable && Model.ScanState.PreviewID.HasValue) diff --git a/PlotLine/Views/Onboarding/ScanReview.cshtml b/PlotLine/Views/Onboarding/ScanReview.cshtml index 7b8e922..45baaf4 100644 --- a/PlotLine/Views/Onboarding/ScanReview.cshtml +++ b/PlotLine/Views/Onboarding/ScanReview.cshtml @@ -105,14 +105,6 @@ @if (!isCharacterStep) { -
diff --git a/PlotLine/wwwroot/css/onboarding.css b/PlotLine/wwwroot/css/onboarding.css index 8b04fa4..84975d5 100644 --- a/PlotLine/wwwroot/css/onboarding.css +++ b/PlotLine/wwwroot/css/onboarding.css @@ -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; diff --git a/PlotLine/wwwroot/js/word-companion-presence.js b/PlotLine/wwwroot/js/word-companion-presence.js index 714cb13..880d4b3 100644 --- a/PlotLine/wwwroot/js/word-companion-presence.js +++ b/PlotLine/wwwroot/js/word-companion-presence.js @@ -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.";