Simplify import wizard and add book context
This commit is contained in:
parent
358913f0ef
commit
dabf63d91d
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; } = [];
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
|
||||
198
PlotLine/Sql/163_Phase25H_BookContextAndLeanImportFlow.sql
Normal file
198
PlotLine/Sql/163_Phase25H_BookContextAndLeanImportFlow.sql
Normal 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
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -253,34 +275,72 @@
|
||||
</div>
|
||||
@if (Model.IsMicrosoftWordPath)
|
||||
{
|
||||
<section class="onboarding-companion-card onboarding-companion-card--compact @(Model.CompanionPresence.IsConnected ? "is-connected" : "is-offline")"
|
||||
data-word-companion-presence
|
||||
data-word-companion-status="@Model.CompanionPresence.Status"
|
||||
aria-label="Word Companion connection">
|
||||
<div class="onboarding-companion-mark" aria-hidden="true">
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="onboarding-companion-body">
|
||||
<p class="eyebrow">Word Companion</p>
|
||||
<h2 data-word-companion-waiting-status>@(Model.CompanionPresence.IsConnected ? "Word Companion Connected" : "Waiting for Word Companion...")</h2>
|
||||
<p data-word-companion-connected-copy>
|
||||
@CompanionScanMessage(Model.CompanionPresence, Model.ScanState)
|
||||
@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>
|
||||
· @Model.ScanState.ChapterCount.ToString("N0") chapter@(Model.ScanState.ChapterCount == 1 ? string.Empty : "s")
|
||||
· @Model.ScanState.TotalWordCount.ToString("N0") words
|
||||
</p>
|
||||
<dl class="onboarding-companion-details">
|
||||
<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>
|
||||
<dt>Version</dt>
|
||||
<dd data-word-companion-version>@(Model.CompanionPresence.CompanionVersion ?? "Waiting")</dd>
|
||||
<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>
|
||||
<div>
|
||||
<dt>Document</dt>
|
||||
<dd data-word-companion-document>@(Model.CompanionPresence.CurrentDocumentName ?? "No document reported yet")</dd>
|
||||
<strong data-onboarding-build-percent></strong>
|
||||
<div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true">
|
||||
<span data-onboarding-build-progress></span>
|
||||
</div>
|
||||
</dl>
|
||||
<section class="onboarding-scan-panel"
|
||||
data-onboarding-scan-panel
|
||||
data-onboarding-auto-review
|
||||
data-scan-status="@Model.ScanState.Status"
|
||||
aria-label="Manuscript scan">
|
||||
<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
|
||||
data-word-companion-status="@Model.CompanionPresence.Status"
|
||||
aria-label="Word Companion connection">
|
||||
<div class="onboarding-companion-mark" aria-hidden="true">
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="onboarding-companion-body">
|
||||
<p class="eyebrow">Word Companion</p>
|
||||
<h2 data-word-companion-waiting-status>@(Model.CompanionPresence.IsConnected ? "Word Companion Connected" : "Waiting for Word Companion...")</h2>
|
||||
<p data-word-companion-connected-copy>
|
||||
@CompanionScanMessage(Model.CompanionPresence, Model.ScanState)
|
||||
</p>
|
||||
<dl class="onboarding-companion-details">
|
||||
<div>
|
||||
<dt>Version</dt>
|
||||
<dd data-word-companion-version>@(Model.CompanionPresence.CompanionVersion ?? "Waiting")</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Document</dt>
|
||||
<dd data-word-companion-document>@(Model.CompanionPresence.CurrentDocumentName ?? "No document reported yet")</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<section class="onboarding-scan-panel"
|
||||
data-onboarding-scan-panel
|
||||
data-onboarding-auto-review
|
||||
data-scan-status="@Model.ScanState.Status"
|
||||
aria-label="Manuscript scan">
|
||||
<div class="onboarding-scan-status">
|
||||
<span data-onboarding-scan-message>@ScanMessage(Model.ScanState)</span>
|
||||
<strong data-onboarding-scan-percent>@ScanPercent(Model.ScanState)</strong>
|
||||
@ -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>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
<div class="onboarding-actions">
|
||||
@if (Model.IsMicrosoftWordPath && reviewedScanAvailable && Model.ScanState.PreviewID.HasValue)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user