From 358913f0eff1d2fefa8d3e24267fe57f4d41df3f Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Thu, 27 Aug 2026 15:27:12 +0000 Subject: [PATCH] Unify core import wizard and scene titles --- PlotLine.Tests/Program.cs | 62 ++- PlotLine/Data/OnboardingBuildRepository.cs | 4 + .../Docs/AI/Chapter-Structure-Prompt-V2.md | 6 +- PlotLine/Models/ChapterStructureModels.cs | 1 + PlotLine/Models/CoreModels.cs | 1 + PlotLine/Models/ManuscriptScanModels.cs | 4 + PlotLine/Models/SceneTitleDisplay.cs | 34 ++ .../ChapterStructureBoundaryNormaliser.cs | 3 + .../Services/ManuscriptScanPreviewStore.cs | 2 + .../ManuscriptStructureAnalysisService.cs | 24 +- PlotLine/Services/OnboardingService.cs | 18 +- .../162_Phase25G_CoreImportSceneTitles.sql | 360 ++++++++++++++++++ PlotLine/ViewModels/OnboardingViewModels.cs | 2 + .../Views/Onboarding/BuildComplete.cshtml | 34 +- PlotLine/Views/Onboarding/Index.cshtml | 4 +- PlotLine/Views/Onboarding/ScanReview.cshtml | 49 ++- PlotLine/wwwroot/css/onboarding.css | 34 ++ 17 files changed, 585 insertions(+), 57 deletions(-) create mode 100644 PlotLine/Models/SceneTitleDisplay.cs create mode 100644 PlotLine/Sql/162_Phase25G_CoreImportSceneTitles.sql diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index cc2997f..ffa444c 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -90,6 +90,10 @@ var tests = new (string Name, Action Test)[] ("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement), ("Word sync retry resumes existing structure", WordSyncRetryResumesExistingStructure), ("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), + ("Generated scene titles persist with provenance", GeneratedSceneTitlesPersistWithProvenance), + ("Scene title display keeps scene numbers visible", SceneTitleDisplayKeepsSceneNumbersVisible), ("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding), ("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures), ("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable), @@ -1849,9 +1853,9 @@ static void CoreImportScanStageDefersScenesAndCharacters() Assert(hub.Contains("CharacterCandidateCount = 0", StringComparison.Ordinal), "Hub normalisation should discard scan-time character counts."); Assert(previewStore.Contains("Scenes = []", StringComparison.Ordinal), "Preview storage should keep scan-time scenes empty."); Assert(previewStore.Contains("CharacterCandidates = []", StringComparison.Ordinal), "Preview storage should keep scan-time character candidates empty."); - Assert(scanReview.Contains("Scenes", StringComparison.Ordinal), "Review Chapters should show the Scenes status card."); - Assert(scanReview.Contains("var displayedSceneCount = isCharacterStep ? Model.SelectedSceneCount : 0;", StringComparison.Ordinal), "Review Chapters should show zero scenes before scene detection."); - Assert(scanReview.Contains("var displayedCharacterCount = isCharacterStep ? Model.SelectedCharacterCount : 0;", StringComparison.Ordinal), "Review Chapters should show zero characters before character review."); + Assert(scanReview.Contains("chapters selected", StringComparison.Ordinal), "Review Chapters should use a compact chapter-only status line."); + Assert(!scanReview.Contains("var displayedSceneCount = isCharacterStep ? Model.SelectedSceneCount : 0;", StringComparison.Ordinal), "Review Chapters should not invent a zero scene status card before scene detection."); + Assert(!scanReview.Contains("var displayedCharacterCount = isCharacterStep ? Model.SelectedCharacterCount : 0;", StringComparison.Ordinal), "Review Chapters should not invent a zero character status card before character review."); Assert(scanReview.Contains("

Chapters

", StringComparison.Ordinal), "Review Chapters section heading should not mention scenes."); Assert(index.Contains("data-onboarding-scan-scenes", StringComparison.Ordinal), "Scan page should display the Scenes counter."); Assert(presence.Contains("Opening chapter review...", StringComparison.Ordinal), "Successful scan should show the immediate auto-navigation state."); @@ -1940,12 +1944,12 @@ static void CoreImportWizardOrdersScenesBeforeCharacters() Assert(controller.Contains("FinaliseCharacterReviewAsync", StringComparison.Ordinal), "Character review should have a distinct post-structure finalisation path."); Assert(onboarding.Contains("ValidateScanStructure(review, preview);\n review = new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Saving chapter review should validate only structure before scene import."); Assert(onboarding.Contains("ValidateScanStructure(review, preview);\n\n var chapterDecisions", StringComparison.Ordinal), "Building approved structure should not validate unresolved character decisions."); - Assert(onboarding.Contains("BuildSceneStructureSummary(chapterDecisions, structuralScenes)", StringComparison.Ordinal), "Detected scene results should be preserved for review before characters."); + Assert(onboarding.Contains("BuildSceneStructureSummary(chapterDecisions, structuralScenes, generateSceneTitles)", StringComparison.Ordinal), "Detected scene results should be preserved before character review."); Assert(onboarding.Contains("ValidateScanReview(new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Character validation should run when finalising character review."); Assert(onboarding.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character finalisation should refuse to run before structural scenes exist."); Assert(complete.Contains("Detected scene structure", StringComparison.Ordinal), "Scene detection result page should show detected counts by chapter."); Assert(complete.Contains("Review characters", StringComparison.Ordinal), "After Word sync, the next onboarding step should be character review."); - Assert(complete.Contains("Word may now be closed if you wish; keep this browser workflow open", StringComparison.Ordinal), "Word safe-close must be distinct from browser/core-import completion."); + Assert(complete.Contains("Keep this browser workflow open to review characters.", StringComparison.Ordinal), "Scene detection completion should continue to character review without marking optional Story Intelligence as required."); } static void CoreImportRemapsCharactersAfterReview() @@ -2513,6 +2517,54 @@ static void Phase21SUsesDurableStoryMemoryAssignments() Assert(development.Contains("RebuildImportSessionAsync", StringComparison.Ordinal), "Development rebuild action must call the durable memory rebuild."); } +static void CoreImportSceneTitlesRideExistingStructuralAiCall() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Chapter-Structure-Prompt-V2.md")); + var model = File.ReadAllText(Path.Combine(root, "Models/ChapterStructureModels.cs")); + var analyzer = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs")); + + Assert(prompt.Contains("suggestedTitle", StringComparison.Ordinal), "Chapter Structure prompt should request a scene title in the existing response."); + Assert(prompt.Contains("2 to 6 words", StringComparison.Ordinal), "Generated title instruction should be concise and bounded."); + Assert(model.Contains("public string? SuggestedTitle", StringComparison.Ordinal), "Chapter boundary model should carry suggested titles."); + Assert(analyzer.Contains("CleanSuggestedTitle(boundary.SuggestedTitle", StringComparison.Ordinal), "Structural analyser should clean title output while processing existing boundaries."); + Assert(!analyzer.Contains("SceneTitle-Prompt", StringComparison.Ordinal), "Scene titles should not use a separate scene-title prompt."); +} + +static void CoreImportSceneTitlePreferenceIsReviewScoped() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + 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 GeneratedSceneTitlesPersistWithProvenance() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var migration = File.ReadAllText(Path.Combine(root, "Sql/162_Phase25G_CoreImportSceneTitles.sql")); + var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.cs")); + + Assert(migration.Contains("ALTER TABLE dbo.Scenes ADD SceneTitleSource", StringComparison.Ordinal), "Scenes should track title provenance."); + Assert(migration.Contains("SceneTitleSource = N'Generated'", StringComparison.Ordinal), "Generated import titles should be stamped as generated."); + Assert(migration.Contains("SceneTitleSource = N'Author'", StringComparison.Ordinal), "Manual scene saves should promote changed titles to author provenance."); + Assert(migration.Contains("ISNULL(SceneTitleSource, N'') <> N'Author'", StringComparison.Ordinal), "Generated title updates must not overwrite author titles."); + Assert(repository.Contains("public string? SuggestedTitle", StringComparison.Ordinal), "Build payload should carry suggested titles through stored-procedure JSON."); +} + +static void SceneTitleDisplayKeepsSceneNumbersVisible() +{ + Assert(SceneTitleDisplay.Format(7, null) == "Scene 7", "Missing titles should render as the scene number."); + Assert(SceneTitleDisplay.Format(7, "Scene 7") == "Scene 7", "Default placeholder titles should not be repeated."); + Assert(SceneTitleDisplay.Format(7, "Behind the Fire Exit") == "Scene 7 \u00b7 Behind the Fire Exit", "Meaningful titles should render after the scene number."); +} + static void Phase21TRepairsDurableCharacterUnderstanding() { var maggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes( diff --git a/PlotLine/Data/OnboardingBuildRepository.cs b/PlotLine/Data/OnboardingBuildRepository.cs index b7c86c9..2841847 100644 --- a/PlotLine/Data/OnboardingBuildRepository.cs +++ b/PlotLine/Data/OnboardingBuildRepository.cs @@ -219,6 +219,7 @@ public sealed class OnboardingBuildSceneRequest public string TemporarySceneKey { get; init; } = string.Empty; public string TemporaryChapterKey { get; init; } = string.Empty; public string Title { get; init; } = string.Empty; + public string? SuggestedTitle { get; init; } public int SortOrder { get; init; } public int WordCount { get; init; } } @@ -260,6 +261,9 @@ public sealed class SceneManuscriptSourceSaveRequest public sealed class SceneManuscriptSourceRecord { public int SceneID { get; init; } + public decimal SceneNumber { get; init; } + public string? SceneTitle { get; init; } + public string? SceneTitleSource { get; init; } public int ProjectID { get; init; } public int BookID { get; init; } public int ChapterID { get; init; } diff --git a/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md b/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md index e43fac7..5f81644 100644 --- a/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md +++ b/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md @@ -12,7 +12,7 @@ You are PlotDirector's Chapter Structure engine. You are an assistant archivist for a novelist. You observe chapter structure only. You do not critique, rewrite, create story entities or infer canon. -Your only task is to identify likely scene boundaries in one chapter and provide a light factual summary for each detected scene. +Your only task is to identify likely scene boundaries in one chapter, provide a short descriptive title for each detected scene, and provide a light factual summary for each detected scene. ## Operating Principles @@ -120,6 +120,7 @@ Each `sceneBoundaries` item must contain exactly: - `sceneNumber` - `startParagraph` - `endParagraph` +- `suggestedTitle` - `structuralSummary` - `confidence` - `reason` @@ -131,6 +132,8 @@ Incorrect examples: `0. 8`, `7 .5`, `0,8`, `"0.8"`. Use `structuralSummary` for a factual, neutral summary of what happens in that scene. It should normally be 25 to 40 words, avoid interpretation, and only use information present in the supplied text. +Use `suggestedTitle` for a concise, factual scene title. It must be 2 to 6 words, memorable without being dramatic, and grounded only in the supplied text. Avoid generic titles such as "Opening Scene", "Conversation", "Chapter Scene", or "Scene 1". Do not truncate or restate the structural summary. + Use short factual reasons, such as: `Location changes from house to train.` @@ -158,6 +161,7 @@ Do not output: "sceneNumber": 1, "startParagraph": 1, "endParagraph": 3, + "suggestedTitle": "Arrival at the Church", "structuralSummary": "The opening scene follows the same continuous action across the supplied paragraphs, keeping the characters in one time and place while the immediate situation is established.", "confidence": 0.86, "reason": "Opening scene remains in the same place and time." diff --git a/PlotLine/Models/ChapterStructureModels.cs b/PlotLine/Models/ChapterStructureModels.cs index d2f5e7a..bbde510 100644 --- a/PlotLine/Models/ChapterStructureModels.cs +++ b/PlotLine/Models/ChapterStructureModels.cs @@ -18,6 +18,7 @@ public sealed class ChapterSceneBoundary public int? SceneNumber { get; init; } public int? StartParagraph { get; init; } public int? EndParagraph { get; init; } + public string? SuggestedTitle { get; init; } public string? StructuralSummary { get; init; } public decimal? Confidence { get; init; } public string? Reason { get; init; } diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 778ebc8..0214fab 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -376,6 +376,7 @@ public sealed class Scene public string? ChapterTitle { get; set; } public decimal SceneNumber { get; set; } public string SceneTitle { get; set; } = string.Empty; + public string? SceneTitleSource { get; set; } public string? Summary { get; set; } public string? StructuralSummary { get; set; } public int? POVCharacterID { get; set; } diff --git a/PlotLine/Models/ManuscriptScanModels.cs b/PlotLine/Models/ManuscriptScanModels.cs index d470013..8275c8c 100644 --- a/PlotLine/Models/ManuscriptScanModels.cs +++ b/PlotLine/Models/ManuscriptScanModels.cs @@ -154,6 +154,7 @@ public sealed class ManuscriptScanReviewDecision { public Guid PreviewID { get; init; } public string Status { get; init; } = ManuscriptScanReviewStatuses.ScanComplete; + public bool GenerateSceneTitles { get; init; } = true; public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow; public IReadOnlyList Chapters { get; init; } = []; public IReadOnlyList Scenes { get; init; } = []; @@ -228,6 +229,7 @@ public sealed class OnboardingSceneStructureDetail { public int SceneNumberWithinChapter { get; init; } public string Title { get; init; } = string.Empty; + public string DisplayTitle => SceneTitleDisplay.Format(SceneNumberWithinChapter, Title); public int WordCount { get; init; } public string? StructuralSummary { get; init; } public int StartParagraph { get; init; } @@ -244,6 +246,8 @@ public sealed class OnboardingBuildSceneMap { public string TemporarySceneKey { get; init; } = string.Empty; public int SceneID { get; init; } + public string? SceneTitle { get; init; } + public string? SceneTitleSource { get; init; } } public sealed class OnboardingBuildCharacterMap diff --git a/PlotLine/Models/SceneTitleDisplay.cs b/PlotLine/Models/SceneTitleDisplay.cs new file mode 100644 index 0000000..563a90d --- /dev/null +++ b/PlotLine/Models/SceneTitleDisplay.cs @@ -0,0 +1,34 @@ +namespace PlotLine.Models; + +public static class SceneTitleDisplay +{ + public static string Format(int sceneNumber, string? title) + => Format((decimal)sceneNumber, title); + + public static string Format(decimal sceneNumber, string? title) + { + var number = $"Scene {sceneNumber:0.##}"; + var cleanTitle = Clean(title); + if (string.IsNullOrWhiteSpace(cleanTitle) || IsDefaultSceneTitle(cleanTitle, sceneNumber)) + { + return number; + } + + return $"{number} \u00b7 {cleanTitle}"; + } + + public static string? Clean(string? title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return null; + } + + var clean = string.Join(' ', title.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + return string.IsNullOrWhiteSpace(clean) ? null : clean; + } + + private static bool IsDefaultSceneTitle(string title, decimal sceneNumber) + => string.Equals(title, $"Scene {sceneNumber:0.##}", StringComparison.OrdinalIgnoreCase) + || string.Equals(title, $"Scene {sceneNumber:N0}", StringComparison.OrdinalIgnoreCase); +} diff --git a/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs b/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs index ad75bd5..eab4fb5 100644 --- a/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs +++ b/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs @@ -109,6 +109,7 @@ public static class ChapterStructureBoundaryNormaliser SceneNumber = sceneNumber, StartParagraph = boundary.StartParagraph, EndParagraph = boundary.EndParagraph, + SuggestedTitle = boundary.SuggestedTitle, StructuralSummary = boundary.StructuralSummary, Confidence = boundary.Confidence, Reason = boundary.Reason, @@ -199,6 +200,7 @@ public static class ChapterStructureBoundaryNormaliser SceneNumber = index + 1, StartParagraph = start, EndParagraph = end, + SuggestedTitle = boundary.SuggestedTitle, StructuralSummary = boundary.StructuralSummary, Confidence = boundary.Confidence, Reason = boundary.Reason, @@ -222,6 +224,7 @@ public static class ChapterStructureBoundaryNormaliser SceneNumber = final.SceneNumber, StartParagraph = final.StartParagraph, EndParagraph = paragraphCount, + SuggestedTitle = final.SuggestedTitle, StructuralSummary = final.StructuralSummary, Confidence = final.Confidence, Reason = final.Reason, diff --git a/PlotLine/Services/ManuscriptScanPreviewStore.cs b/PlotLine/Services/ManuscriptScanPreviewStore.cs index 94d1726..41919a8 100644 --- a/PlotLine/Services/ManuscriptScanPreviewStore.cs +++ b/PlotLine/Services/ManuscriptScanPreviewStore.cs @@ -179,6 +179,7 @@ public sealed class ManuscriptScanPreviewStore( { PreviewID = review.PreviewID, Status = review.Status, + GenerateSceneTitles = review.GenerateSceneTitles, UpdatedUtc = DateTime.UtcNow, Chapters = review.Chapters, Scenes = review.Scenes, @@ -299,6 +300,7 @@ public sealed class ManuscriptScanPreviewStore( return new ManuscriptScanReviewDecision { PreviewID = preview.PreviewID, + GenerateSceneTitles = true, Status = ManuscriptScanReviewStatuses.ScanComplete, Chapters = preview.Chapters .OrderBy(chapter => chapter.ChapterNumber) diff --git a/PlotLine/Services/ManuscriptStructureAnalysisService.cs b/PlotLine/Services/ManuscriptStructureAnalysisService.cs index a65642d..5ea5fec 100644 --- a/PlotLine/Services/ManuscriptStructureAnalysisService.cs +++ b/PlotLine/Services/ManuscriptStructureAnalysisService.cs @@ -135,12 +135,13 @@ public sealed class ManuscriptStructureAnalysisService( var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList(); var sourceText = BuildSourceText(request.SourceParagraphs, startMap.WordParagraphIndex, endMap.WordParagraphIndex); + var suggestedTitle = CleanSuggestedTitle(boundary.SuggestedTitle, sceneNumber); scenes.Add(new ManuscriptStructureSceneBoundary { TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}", TemporaryChapterKey = request.TemporaryChapterKey, SceneNumberWithinChapter = sceneNumber, - Title = $"Scene {sceneNumber:N0}", + Title = suggestedTitle ?? string.Empty, WordCount = sceneParagraphs.Sum(CountWords), SourceText = sourceText, StartParagraph = start, @@ -421,6 +422,26 @@ public sealed class ManuscriptStructureAnalysisService( return clean.Length <= maxLength ? clean : clean[..maxLength].Trim(); } + + private static string? CleanSuggestedTitle(string? value, int sceneNumber) + { + var clean = Trim(value, 80); + if (string.IsNullOrWhiteSpace(clean)) + { + return null; + } + + clean = clean.Trim(' ', '.', ':', ';', '-', '"', '\''); + if (string.IsNullOrWhiteSpace(clean) + || string.Equals(clean, $"Scene {sceneNumber}", StringComparison.OrdinalIgnoreCase) + || clean.StartsWith("Untitled", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var wordCount = clean.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; + return wordCount is >= 2 and <= 6 ? clean : null; + } } public sealed class ManuscriptStructureAnalysisRequest @@ -457,6 +478,7 @@ public sealed class ManuscriptStructureSceneBoundary public string TemporaryChapterKey { get; init; } = string.Empty; public int SceneNumberWithinChapter { get; init; } public string Title { get; init; } = string.Empty; + public string? SuggestedTitle => SceneTitleDisplay.Clean(Title); public int WordCount { get; init; } public string SourceText { get; init; } = string.Empty; public int StartParagraph { get; init; } diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index eabb210..6cb81da 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -194,6 +194,7 @@ public sealed class OnboardingService( { PreviewID = preview.PreviewID, Status = ManuscriptScanReviewStatuses.ReviewInProgress, + GenerateSceneTitles = form.GenerateSceneTitles, Chapters = chapterDecisions, Scenes = sceneDecisions, Characters = characterDecisions @@ -207,6 +208,7 @@ public sealed class OnboardingService( { PreviewID = review.PreviewID, Status = ManuscriptScanReviewStatuses.ReadyToImport, + GenerateSceneTitles = review.GenerateSceneTitles, Chapters = review.Chapters, Scenes = review.Scenes, Characters = review.Characters @@ -232,6 +234,7 @@ public sealed class OnboardingService( } var (userId, preview, review, _, _, _, _) = context.Value; + var generateSceneTitles = review.GenerateSceneTitles; if (!string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal)) { throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters."); @@ -315,7 +318,8 @@ public sealed class OnboardingService( { TemporarySceneKey = scene.TemporarySceneKey, TemporaryChapterKey = scene.TemporaryChapterKey, - Title = scene.Title, + Title = generateSceneTitles ? scene.SuggestedTitle ?? string.Empty : string.Empty, + SuggestedTitle = generateSceneTitles ? scene.SuggestedTitle : null, SortOrder = scene.SceneNumberWithinChapter * 10, WordCount = scene.WordCount }).ToList(), @@ -346,7 +350,7 @@ public sealed class OnboardingService( CharacterMappings = result.CharacterMappings, ChapterMarkerInstructions = BuildChapterMarkerInstructions(preview, result.ChapterMappings), SceneMarkerInstructions = BuildSceneMarkerInstructions(structuralScenes, result.SceneMappings), - SceneStructure = BuildSceneStructureSummary(chapterDecisions, structuralScenes), + SceneStructure = BuildSceneStructureSummary(chapterDecisions, structuralScenes, generateSceneTitles), MarkerWarning = result.MarkerWarning }; await SaveStructuralChapterSourcesAsync(preview, result); @@ -453,6 +457,7 @@ public sealed class OnboardingService( { PreviewID = review.PreviewID, Status = review.Status, + GenerateSceneTitles = review.GenerateSceneTitles, Chapters = review.Chapters, Scenes = review.Scenes, Characters = characterDecisions @@ -485,6 +490,7 @@ public sealed class OnboardingService( { PreviewID = review.PreviewID, Status = ManuscriptScanReviewStatuses.CoreImportComplete, + GenerateSceneTitles = review.GenerateSceneTitles, Chapters = review.Chapters, Scenes = review.Scenes, Characters = characterDecisions @@ -595,7 +601,8 @@ public sealed class OnboardingService( private static IReadOnlyList BuildSceneStructureSummary( IReadOnlyList chapters, - IReadOnlyList scenes) + IReadOnlyList scenes, + bool generateSceneTitles) => chapters .OrderBy(chapter => chapter.ChapterNumber) .Select(chapter => @@ -614,7 +621,7 @@ public sealed class OnboardingService( Scenes = chapterScenes.Select(scene => new OnboardingSceneStructureDetail { SceneNumberWithinChapter = scene.SceneNumberWithinChapter, - Title = scene.Title, + Title = generateSceneTitles ? scene.SuggestedTitle ?? string.Empty : string.Empty, WordCount = scene.WordCount, StructuralSummary = scene.StructuralSummary, StartParagraph = scene.StartParagraph, @@ -651,7 +658,7 @@ public sealed class OnboardingService( Scenes = chapterSources.Select((scene, sceneIndex) => new OnboardingSceneStructureDetail { SceneNumberWithinChapter = sceneIndex + 1, - Title = $"Scene {sceneIndex + 1:N0}", + Title = scene.SceneTitle ?? string.Empty, WordCount = scene.SourceWordCount, StructuralSummary = scene.StructuralSummary, StartParagraph = scene.SourceStartParagraph ?? 0, @@ -892,6 +899,7 @@ public sealed class OnboardingService( { PreviewID = preview.PreviewID, ReviewStatus = review.Status, + GenerateSceneTitles = review.GenerateSceneTitles, DocumentTitle = string.IsNullOrWhiteSpace(preview.DocumentTitle) ? "Word manuscript" : preview.DocumentTitle!, SelectedProjectName = project.ProjectName, SelectedBookTitle = BookOptionTitle(book), diff --git a/PlotLine/Sql/162_Phase25G_CoreImportSceneTitles.sql b/PlotLine/Sql/162_Phase25G_CoreImportSceneTitles.sql new file mode 100644 index 0000000..f2c9f31 --- /dev/null +++ b/PlotLine/Sql/162_Phase25G_CoreImportSceneTitles.sql @@ -0,0 +1,360 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF COL_LENGTH(N'dbo.Scenes', N'SceneTitleSource') IS NULL +BEGIN + ALTER TABLE dbo.Scenes ADD SceneTitleSource nvarchar(20) NULL; +END; +GO + +UPDATE dbo.Scenes +SET SceneTitleSource = N'Author' +WHERE SceneTitleSource IS NULL + AND NULLIF(LTRIM(RTRIM(SceneTitle)), N'') IS NOT NULL + AND SceneTitle NOT LIKE N'Scene [0-9]%'; +GO + +CREATE OR ALTER PROCEDURE dbo.Scene_Save + @SceneID int = NULL, @ChapterID int, @SceneNumber decimal(10,2), @SceneTitle nvarchar(200), @Summary nvarchar(max) = NULL, + @TimeModeID int, @StartDateTime datetime2 = NULL, @EndDateTime datetime2 = NULL, @DurationAmount decimal(10,2) = NULL, + @DurationUnitID int = NULL, @RelativeTimeText nvarchar(200) = NULL, @TimeConfidenceID int, + @ScenePurposeNotes nvarchar(max) = NULL, @SceneOutcomeNotes nvarchar(max) = NULL, @RevisionStatusID int, + @PrimaryLocationID int = NULL, @FloorPlanID int = NULL, @InitialFloorPlanFloorID int = NULL +AS +BEGIN + SET NOCOUNT ON; + DECLARE @NextSortOrder int; + DECLARE @CleanSceneTitle nvarchar(200) = ISNULL(LEFT(LTRIM(RTRIM(@SceneTitle)), 200), N''); + IF @SceneID IS NULL OR @SceneID = 0 + BEGIN + SELECT @NextSortOrder = ISNULL(MAX(SortOrder), 0) + 10 FROM dbo.Scenes WHERE ChapterID = @ChapterID; + INSERT dbo.Scenes (ChapterID, SceneNumber, SceneTitle, SceneTitleSource, Summary, SortOrder, TimeModeID, StartDateTime, EndDateTime, + DurationAmount, DurationUnitID, RelativeTimeText, TimeConfidenceID, ScenePurposeNotes, SceneOutcomeNotes, + RevisionStatusID, PrimaryLocationID, FloorPlanID, InitialFloorPlanFloorID) + VALUES (@ChapterID, @SceneNumber, @CleanSceneTitle, CASE WHEN NULLIF(@CleanSceneTitle, N'') IS NULL THEN NULL ELSE N'Author' END, @Summary, @NextSortOrder, @TimeModeID, @StartDateTime, @EndDateTime, + @DurationAmount, @DurationUnitID, @RelativeTimeText, @TimeConfidenceID, @ScenePurposeNotes, @SceneOutcomeNotes, + @RevisionStatusID, @PrimaryLocationID, @FloorPlanID, @InitialFloorPlanFloorID); + SET @SceneID = CAST(SCOPE_IDENTITY() AS int); + END + ELSE + BEGIN + UPDATE dbo.Scenes + SET SceneNumber = @SceneNumber, + SceneTitle = @CleanSceneTitle, + SceneTitleSource = CASE + WHEN ISNULL(SceneTitle, N'') <> @CleanSceneTitle THEN CASE WHEN NULLIF(@CleanSceneTitle, N'') IS NULL THEN NULL ELSE N'Author' END + ELSE SceneTitleSource + END, + Summary = @Summary, + TimeModeID = @TimeModeID, + StartDateTime = @StartDateTime, + EndDateTime = @EndDateTime, + DurationAmount = @DurationAmount, + DurationUnitID = @DurationUnitID, + RelativeTimeText = @RelativeTimeText, + TimeConfidenceID = @TimeConfidenceID, + ScenePurposeNotes = @ScenePurposeNotes, + SceneOutcomeNotes = @SceneOutcomeNotes, + RevisionStatusID = @RevisionStatusID, + PrimaryLocationID = @PrimaryLocationID, + FloorPlanID = @FloorPlanID, + InitialFloorPlanFloorID = @InitialFloorPlanFloorID, + UpdatedDate = SYSUTCDATETIME() + WHERE SceneID = @SceneID; + END + SELECT @SceneID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_Create + @UserID int, + @PreviewID uniqueidentifier, + @ProjectID int, + @BookID int, + @DocumentGuid uniqueidentifier, + @BindingVersion int = 1, + @ChaptersJson nvarchar(max), + @ScenesJson nvarchar(max), + @CharactersJson nvarchar(max) +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + IF @DocumentGuid IS NULL OR @DocumentGuid = '00000000-0000-0000-0000-000000000000' + SET @DocumentGuid = NEWID(); + + DECLARE @ExistingBuildID int; + SELECT @ExistingBuildID = OnboardingManuscriptBuildID + FROM dbo.OnboardingManuscriptBuilds + WHERE PreviewID = @PreviewID AND UserID = @UserID; + + IF @ExistingBuildID IS NOT NULL + BEGIN + SELECT OnboardingManuscriptBuildID AS BuildID, PreviewID, ProjectID, BookID, ManuscriptDocumentID, Status, MarkerStatus, + ChaptersCreated, ScenesCreated, CharactersCreated, CharactersReused, SceneAppearancesCreated, + CAST(1 AS bit) AS AlreadyBuilt, + N'This manuscript has already been built into PlotDirector.' AS Message + FROM dbo.OnboardingManuscriptBuilds + WHERE OnboardingManuscriptBuildID = @ExistingBuildID; + + SELECT TemporaryChapterKey, ChapterID FROM dbo.OnboardingManuscriptBuildChapterMap WHERE OnboardingManuscriptBuildID = @ExistingBuildID ORDER BY TemporaryChapterKey; + SELECT map.TemporarySceneKey, map.SceneID, scene.SceneTitle, scene.SceneTitleSource + FROM dbo.OnboardingManuscriptBuildSceneMap map + INNER JOIN dbo.Scenes scene ON scene.SceneID = map.SceneID + WHERE map.OnboardingManuscriptBuildID = @ExistingBuildID + ORDER BY map.TemporarySceneKey; + SELECT TemporaryCharacterKey, CharacterID, Created FROM dbo.OnboardingManuscriptBuildCharacterMap WHERE OnboardingManuscriptBuildID = @ExistingBuildID ORDER BY TemporaryCharacterKey; + RETURN; + END; + + IF NOT EXISTS + ( + SELECT 1 + FROM dbo.Books b + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE b.BookID = @BookID + AND b.ProjectID = @ProjectID + AND b.IsArchived = 0 + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1 + ) + RETURN; + + DECLARE @Chapters table (TemporaryChapterKey nvarchar(80) NOT NULL PRIMARY KEY, Title nvarchar(200) NOT NULL, SortOrder int NOT NULL, WordCount int NOT NULL); + DECLARE @Scenes table (TemporarySceneKey nvarchar(80) NOT NULL PRIMARY KEY, TemporaryChapterKey nvarchar(80) NOT NULL, Title nvarchar(200) NOT NULL, SuggestedTitle nvarchar(200) NULL, SortOrder int NOT NULL, WordCount int NOT NULL); + DECLARE @Characters table (TemporaryCharacterKey nvarchar(80) NOT NULL PRIMARY KEY, Name nvarchar(200) NOT NULL, ExistingCharacterID int NULL); + + INSERT @Chapters (TemporaryChapterKey, Title, SortOrder, WordCount) + SELECT LEFT(JSON_VALUE(value, '$.TemporaryChapterKey'), 80), + LEFT(LTRIM(RTRIM(JSON_VALUE(value, '$.Title'))), 200), + TRY_CONVERT(int, JSON_VALUE(value, '$.SortOrder')), + COALESCE(TRY_CONVERT(int, JSON_VALUE(value, '$.WordCount')), 0) + FROM OPENJSON(@ChaptersJson) + WHERE NULLIF(JSON_VALUE(value, '$.TemporaryChapterKey'), N'') IS NOT NULL + AND NULLIF(LTRIM(RTRIM(JSON_VALUE(value, '$.Title'))), N'') IS NOT NULL; + + INSERT @Scenes (TemporarySceneKey, TemporaryChapterKey, Title, SuggestedTitle, SortOrder, WordCount) + SELECT LEFT(JSON_VALUE(value, '$.TemporarySceneKey'), 80), + LEFT(JSON_VALUE(value, '$.TemporaryChapterKey'), 80), + LEFT(ISNULL(LTRIM(RTRIM(JSON_VALUE(value, '$.Title'))), N''), 200), + LEFT(NULLIF(LTRIM(RTRIM(JSON_VALUE(value, '$.SuggestedTitle'))), N''), 200), + TRY_CONVERT(int, JSON_VALUE(value, '$.SortOrder')), + COALESCE(TRY_CONVERT(int, JSON_VALUE(value, '$.WordCount')), 0) + FROM OPENJSON(@ScenesJson) + WHERE NULLIF(JSON_VALUE(value, '$.TemporarySceneKey'), N'') IS NOT NULL + AND NULLIF(JSON_VALUE(value, '$.TemporaryChapterKey'), N'') IS NOT NULL; + + INSERT @Characters (TemporaryCharacterKey, Name, ExistingCharacterID) + SELECT LEFT(JSON_VALUE(value, '$.TemporaryCharacterKey'), 80), + LEFT(LTRIM(RTRIM(JSON_VALUE(value, '$.Name'))), 200), + TRY_CONVERT(int, JSON_VALUE(value, '$.ExistingCharacterID')) + FROM OPENJSON(@CharactersJson) + WHERE NULLIF(JSON_VALUE(value, '$.TemporaryCharacterKey'), N'') IS NOT NULL + AND NULLIF(LTRIM(RTRIM(JSON_VALUE(value, '$.Name'))), N'') IS NOT NULL; + + IF NOT EXISTS (SELECT 1 FROM @Chapters) OR EXISTS (SELECT 1 FROM @Scenes s WHERE NOT EXISTS (SELECT 1 FROM @Chapters c WHERE c.TemporaryChapterKey = s.TemporaryChapterKey)) + RETURN; + + IF EXISTS (SELECT 1 FROM @Chapters WHERE SortOrder IS NULL OR SortOrder <= 0 OR WordCount < 0) + RETURN; + IF EXISTS (SELECT 1 FROM @Scenes WHERE SortOrder IS NULL OR SortOrder <= 0 OR WordCount < 0) + RETURN; + + DECLARE @PlannedRevisionStatusID int = (SELECT TOP (1) RevisionStatusID FROM dbo.RevisionStatuses WHERE StatusName = N'Planned' ORDER BY SortOrder); + DECLARE @DefaultTimeModeID int = (SELECT TOP (1) TimeModeID FROM dbo.TimeModes ORDER BY CASE WHEN TimeModeName = N'Unknown' THEN 0 ELSE 1 END, SortOrder, TimeModeID); + DECLARE @DefaultTimeConfidenceID int = (SELECT TOP (1) TimeConfidenceID FROM dbo.TimeConfidences ORDER BY CASE WHEN TimeConfidenceName = N'Unknown' THEN 0 ELSE 1 END, SortOrder, TimeConfidenceID); + + DECLARE @ChapterMap table (TemporaryChapterKey nvarchar(80) NOT NULL PRIMARY KEY, ChapterID int NOT NULL); + DECLARE @SceneMap table (TemporarySceneKey nvarchar(80) NOT NULL PRIMARY KEY, SceneID int NOT NULL); + DECLARE @CharacterMap table (TemporaryCharacterKey nvarchar(80) NOT NULL PRIMARY KEY, CharacterID int NOT NULL, Created bit NOT NULL); + DECLARE @BuildID int; + DECLARE @ManuscriptDocumentID int; + DECLARE @LinkedUtc datetime2 = SYSUTCDATETIME(); + + BEGIN TRANSACTION; + + IF EXISTS (SELECT 1 FROM dbo.ManuscriptDocuments WHERE DocumentGuid = @DocumentGuid AND IsActive = 1 AND BookID <> @BookID) + THROW 51000, 'This manuscript document is already linked to another book.', 1; + + SELECT @ManuscriptDocumentID = ManuscriptDocumentID FROM dbo.ManuscriptDocuments WHERE BookID = @BookID AND IsActive = 1; + IF @ManuscriptDocumentID IS NULL + BEGIN + INSERT dbo.ManuscriptDocuments (BookID, ProjectID, DocumentGuid, BindingVersion, CreatedByUserID, LastOpenedUtc, LastSyncUtc) + VALUES (@BookID, @ProjectID, @DocumentGuid, @BindingVersion, @UserID, @LinkedUtc, @LinkedUtc); + SET @ManuscriptDocumentID = CAST(SCOPE_IDENTITY() AS int); + END + ELSE + BEGIN + UPDATE dbo.ManuscriptDocuments + SET DocumentGuid = @DocumentGuid, ProjectID = @ProjectID, BindingVersion = @BindingVersion, LastOpenedUtc = @LinkedUtc, LastSyncUtc = @LinkedUtc + WHERE ManuscriptDocumentID = @ManuscriptDocumentID; + END; + + INSERT dbo.OnboardingManuscriptBuilds (PreviewID, UserID, ProjectID, BookID, ManuscriptDocumentID) + VALUES (@PreviewID, @UserID, @ProjectID, @BookID, @ManuscriptDocumentID); + SET @BuildID = CAST(SCOPE_IDENTITY() AS int); + + DECLARE @TempChapterKey nvarchar(80), @Title nvarchar(200), @SortOrder int, @WordCount int, @ChapterID int, @ChapterNumber decimal(9,2); + DECLARE chapter_cursor CURSOR LOCAL FAST_FORWARD FOR SELECT TemporaryChapterKey, Title, SortOrder, WordCount FROM @Chapters ORDER BY SortOrder, TemporaryChapterKey; + OPEN chapter_cursor; + FETCH NEXT FROM chapter_cursor INTO @TempChapterKey, @Title, @SortOrder, @WordCount; + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @ChapterNumber = (SELECT COUNT(*) + 1 FROM @ChapterMap); + INSERT dbo.Chapters (BookID, ChapterNumber, ChapterTitle, Summary, SortOrder, RevisionStatusID, IsLinkedToManuscript, ManuscriptDocumentID, ExternalReference, LinkedUtc) + VALUES (@BookID, @ChapterNumber, @Title, NULL, @SortOrder, @PlannedRevisionStatusID, 1, @ManuscriptDocumentID, @TempChapterKey, @LinkedUtc); + SET @ChapterID = CAST(SCOPE_IDENTITY() AS int); + INSERT @ChapterMap VALUES (@TempChapterKey, @ChapterID); + INSERT dbo.OnboardingManuscriptBuildChapterMap VALUES (@BuildID, @TempChapterKey, @ChapterID); + FETCH NEXT FROM chapter_cursor INTO @TempChapterKey, @Title, @SortOrder, @WordCount; + END; + CLOSE chapter_cursor; DEALLOCATE chapter_cursor; + + DECLARE @TempSceneKey nvarchar(80), @TempSceneChapterKey nvarchar(80), @SceneID int, @SceneNumber decimal(9,2), @SuggestedTitle nvarchar(200), @SceneTitleSource nvarchar(20), @PersistedTitle nvarchar(200); + DECLARE scene_cursor CURSOR LOCAL FAST_FORWARD FOR + SELECT TemporarySceneKey, TemporaryChapterKey, Title, SuggestedTitle, SortOrder, WordCount FROM @Scenes ORDER BY TemporaryChapterKey, SortOrder, TemporarySceneKey; + OPEN scene_cursor; + FETCH NEXT FROM scene_cursor INTO @TempSceneKey, @TempSceneChapterKey, @Title, @SuggestedTitle, @SortOrder, @WordCount; + WHILE @@FETCH_STATUS = 0 + BEGIN + SELECT @ChapterID = ChapterID FROM @ChapterMap WHERE TemporaryChapterKey = @TempSceneChapterKey; + SET @SceneNumber = (SELECT COUNT(*) + 1 FROM @SceneMap sm INNER JOIN @Scenes s ON s.TemporarySceneKey = sm.TemporarySceneKey WHERE s.TemporaryChapterKey = @TempSceneChapterKey); + SET @PersistedTitle = COALESCE(NULLIF(LTRIM(RTRIM(@SuggestedTitle)), N''), NULLIF(LTRIM(RTRIM(@Title)), N''), N''); + SET @SceneTitleSource = CASE WHEN NULLIF(LTRIM(RTRIM(@SuggestedTitle)), N'') IS NOT NULL THEN N'Generated' ELSE NULL END; + IF @SceneNumber = 1 + BEGIN + SELECT TOP (1) @SceneID = SceneID FROM dbo.Scenes WHERE ChapterID = @ChapterID AND IsArchived = 0 ORDER BY SortOrder, SceneID; + IF @SceneID IS NOT NULL + BEGIN + UPDATE dbo.Scenes + SET SceneNumber = @SceneNumber, + SceneTitle = CASE + WHEN @SceneTitleSource = N'Generated' AND ISNULL(SceneTitleSource, N'') <> N'Author' THEN @PersistedTitle + WHEN NULLIF(SceneTitle, N'') IS NULL THEN @PersistedTitle + ELSE SceneTitle + END, + SceneTitleSource = CASE + WHEN @SceneTitleSource = N'Generated' AND ISNULL(SceneTitleSource, N'') <> N'Author' THEN N'Generated' + WHEN NULLIF(SceneTitle, N'') IS NULL THEN @SceneTitleSource + ELSE SceneTitleSource + END, + SortOrder = @SortOrder, IsLinkedToManuscript = 1, + ManuscriptDocumentID = @ManuscriptDocumentID, ExternalReference = @TempSceneKey, LinkedUtc = @LinkedUtc, UpdatedDate = SYSUTCDATETIME() + WHERE SceneID = @SceneID; + END + ELSE + BEGIN + INSERT dbo.Scenes (ChapterID, SceneNumber, SceneTitle, SceneTitleSource, Summary, SortOrder, TimeModeID, TimeConfidenceID, RevisionStatusID, IsLinkedToManuscript, ManuscriptDocumentID, ExternalReference, LinkedUtc) + VALUES (@ChapterID, @SceneNumber, @PersistedTitle, @SceneTitleSource, NULL, @SortOrder, @DefaultTimeModeID, @DefaultTimeConfidenceID, @PlannedRevisionStatusID, 1, @ManuscriptDocumentID, @TempSceneKey, @LinkedUtc); + SET @SceneID = CAST(SCOPE_IDENTITY() AS int); + END; + END + ELSE + BEGIN + INSERT dbo.Scenes (ChapterID, SceneNumber, SceneTitle, SceneTitleSource, Summary, SortOrder, TimeModeID, TimeConfidenceID, RevisionStatusID, IsLinkedToManuscript, ManuscriptDocumentID, ExternalReference, LinkedUtc) + VALUES (@ChapterID, @SceneNumber, @PersistedTitle, @SceneTitleSource, NULL, @SortOrder, @DefaultTimeModeID, @DefaultTimeConfidenceID, @PlannedRevisionStatusID, 1, @ManuscriptDocumentID, @TempSceneKey, @LinkedUtc); + SET @SceneID = CAST(SCOPE_IDENTITY() AS int); + END; + IF EXISTS (SELECT 1 FROM dbo.SceneWorkflow WHERE SceneID = @SceneID) + UPDATE dbo.SceneWorkflow SET ActualWordCount = @WordCount, UpdatedDate = SYSUTCDATETIME() WHERE SceneID = @SceneID; + ELSE + INSERT dbo.SceneWorkflow (SceneID, ActualWordCount) VALUES (@SceneID, @WordCount); + INSERT @SceneMap VALUES (@TempSceneKey, @SceneID); + INSERT dbo.OnboardingManuscriptBuildSceneMap VALUES (@BuildID, @TempSceneKey, @SceneID); + SET @SceneID = NULL; + FETCH NEXT FROM scene_cursor INTO @TempSceneKey, @TempSceneChapterKey, @Title, @SuggestedTitle, @SortOrder, @WordCount; + END; + CLOSE scene_cursor; DEALLOCATE scene_cursor; + + DECLARE @TempCharacterKey nvarchar(80), @ExistingCharacterID int, @CharacterID int, @Created bit; + DECLARE character_cursor CURSOR LOCAL FAST_FORWARD FOR SELECT TemporaryCharacterKey, Name, ExistingCharacterID FROM @Characters ORDER BY Name, TemporaryCharacterKey; + OPEN character_cursor; + FETCH NEXT FROM character_cursor INTO @TempCharacterKey, @Title, @ExistingCharacterID; + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @Created = 0; + SELECT @CharacterID = CharacterID FROM dbo.Characters WHERE CharacterID = @ExistingCharacterID AND ProjectID = @ProjectID AND IsArchived = 0; + IF @CharacterID IS NULL + SELECT TOP (1) @CharacterID = CharacterID FROM dbo.Characters WHERE ProjectID = @ProjectID AND IsArchived = 0 AND UPPER(LTRIM(RTRIM(CharacterName))) = UPPER(@Title); + IF @CharacterID IS NULL + BEGIN + INSERT dbo.Characters (ProjectID, CharacterName, ShortName, CharacterImportance, ShowInQuickAddBar, DefaultDescription) + VALUES (@ProjectID, @Title, NULL, 8, 1, NULL); + SET @CharacterID = CAST(SCOPE_IDENTITY() AS int); + SET @Created = 1; + END; + INSERT @CharacterMap VALUES (@TempCharacterKey, @CharacterID, @Created); + INSERT dbo.OnboardingManuscriptBuildCharacterMap VALUES (@BuildID, @TempCharacterKey, @CharacterID, @Created); + SET @CharacterID = NULL; + FETCH NEXT FROM character_cursor INTO @TempCharacterKey, @Title, @ExistingCharacterID; + END; + CLOSE character_cursor; DEALLOCATE character_cursor; + + UPDATE dbo.OnboardingManuscriptBuilds + SET ChaptersCreated = (SELECT COUNT(*) FROM @ChapterMap), + ScenesCreated = (SELECT COUNT(*) FROM @SceneMap), + CharactersCreated = (SELECT COUNT(*) FROM @CharacterMap WHERE Created = 1), + CharactersReused = (SELECT COUNT(*) FROM @CharacterMap WHERE Created = 0), + UpdatedUtc = SYSUTCDATETIME() + WHERE OnboardingManuscriptBuildID = @BuildID; + + COMMIT TRANSACTION; + + SELECT OnboardingManuscriptBuildID AS BuildID, PreviewID, ProjectID, BookID, ManuscriptDocumentID, Status, MarkerStatus, + ChaptersCreated, ScenesCreated, CharactersCreated, CharactersReused, SceneAppearancesCreated, + CAST(0 AS bit) AS AlreadyBuilt, + N'Your project structure has been created.' AS Message + FROM dbo.OnboardingManuscriptBuilds WHERE OnboardingManuscriptBuildID = @BuildID; + SELECT TemporaryChapterKey, ChapterID FROM dbo.OnboardingManuscriptBuildChapterMap WHERE OnboardingManuscriptBuildID = @BuildID ORDER BY TemporaryChapterKey; + SELECT map.TemporarySceneKey, map.SceneID, scene.SceneTitle, scene.SceneTitleSource + FROM dbo.OnboardingManuscriptBuildSceneMap map + INNER JOIN dbo.Scenes scene ON scene.SceneID = map.SceneID + WHERE map.OnboardingManuscriptBuildID = @BuildID + ORDER BY map.TemporarySceneKey; + SELECT TemporaryCharacterKey, CharacterID, Created FROM dbo.OnboardingManuscriptBuildCharacterMap WHERE OnboardingManuscriptBuildID = @BuildID ORDER BY TemporaryCharacterKey; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneManuscriptSource_ListByChapter + @ChapterID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT source.SceneID, scene.SceneNumber, scene.SceneTitle, scene.SceneTitleSource, + source.ProjectID, source.BookID, source.ChapterID, source.SourceText, + source.SourceWordCount, source.SourceCharacterCount, source.SourceStartParagraph, source.SourceEndParagraph, + source.WordStartParagraphIndex, source.WordEndParagraphIndex, source.SourceKind, scene.StructuralSummary, + source.CreatedUtc, source.UpdatedUtc + FROM dbo.SceneManuscriptSources source + INNER JOIN dbo.Scenes scene ON scene.SceneID = source.SceneID + WHERE source.ChapterID = @ChapterID + AND scene.IsArchived = 0 + ORDER BY scene.SortOrder, scene.SceneNumber, scene.SceneID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneManuscriptSource_ListByBook + @BookID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT source.SceneID, scene.SceneNumber, scene.SceneTitle, scene.SceneTitleSource, + source.ProjectID, source.BookID, source.ChapterID, source.SourceText, + source.SourceWordCount, source.SourceCharacterCount, source.SourceStartParagraph, source.SourceEndParagraph, + source.WordStartParagraphIndex, source.WordEndParagraphIndex, source.SourceKind, scene.StructuralSummary, + source.CreatedUtc, source.UpdatedUtc + FROM dbo.SceneManuscriptSources source + INNER JOIN dbo.Scenes scene ON scene.SceneID = source.SceneID + INNER JOIN dbo.Chapters chapter ON chapter.ChapterID = scene.ChapterID + WHERE source.BookID = @BookID + AND scene.IsArchived = 0 + ORDER BY chapter.SortOrder, chapter.ChapterNumber, scene.SortOrder, scene.SceneNumber, scene.SceneID; +END; +GO diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 0e54c01..619ed3e 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -699,6 +699,7 @@ public sealed class ManuscriptScanReviewViewModel { public Guid PreviewID { get; init; } public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete; + public bool GenerateSceneTitles { get; init; } = true; public string DocumentTitle { get; init; } = "Word manuscript"; public string SelectedProjectName { get; init; } = string.Empty; public string SelectedBookTitle { get; init; } = string.Empty; @@ -779,6 +780,7 @@ public sealed class ManuscriptScanExistingCharacterOptionViewModel public sealed class ManuscriptScanReviewForm { public Guid PreviewID { get; set; } + public bool GenerateSceneTitles { get; set; } = true; public List Chapters { get; set; } = []; public List Scenes { get; set; } = []; public List Characters { get; set; } = []; diff --git a/PlotLine/Views/Onboarding/BuildComplete.cshtml b/PlotLine/Views/Onboarding/BuildComplete.cshtml index 22318cc..eb73667 100644 --- a/PlotLine/Views/Onboarding/BuildComplete.cshtml +++ b/PlotLine/Views/Onboarding/BuildComplete.cshtml @@ -16,7 +16,7 @@

@if (coreComplete) { - @:PlotDirector created the approved chapters and scenes, confirmed the Word markers were saved, and applied your character review. Core import is complete. + @:@Model.ChaptersCreated.ToString("N0") chapters · @Model.ScenesCreated.ToString("N0") scenes · @((Model.CharactersCreated + Model.CharactersReused).ToString("N0")) characters. Core import is complete. } else if (Model.AlreadyBuilt) { @@ -24,7 +24,7 @@ } else { - @:PlotDirector created the approved chapters and detected scenes, persisted their manuscript text, and confirmed the Word markers were saved. Word may now be closed if you wish; keep this browser workflow open to review characters. + @:@Model.ScenesCreated.ToString("N0") scenes detected across @Model.ChaptersCreated.ToString("N0") chapters. Keep this browser workflow open to review characters. }

@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning)) @@ -33,24 +33,14 @@ } -
-
- Chapters - @Model.ChaptersCreated -
-
- Scenes - @Model.ScenesCreated -
-
- Characters created now - @(Model.CharactersCreated + Model.CharactersReused) -
-
- Scene appearances now - @Model.SceneAppearancesCreated -
-
+

+ @Model.ChaptersCreated.ToString("N0") chapter@(Model.ChaptersCreated == 1 ? string.Empty : "s") · + @Model.ScenesCreated.ToString("N0") scene@(Model.ScenesCreated == 1 ? string.Empty : "s") + @if (coreComplete) + { + @:· @((Model.CharactersCreated + Model.CharactersReused).ToString("N0")) character@((Model.CharactersCreated + Model.CharactersReused) == 1 ? string.Empty : "s") + } +

@if (Model.SceneStructure.Any()) { @@ -71,8 +61,8 @@ @foreach (var scene in chapter.Scenes) {
  • - @scene.Title - @scene.WordCount.ToString("N0") words / paragraphs @scene.StartParagraph-@scene.EndParagraph + @scene.DisplayTitle + @scene.WordCount.ToString("N0") words @if (!string.IsNullOrWhiteSpace(scene.StructuralSummary)) {

    @scene.StructuralSummary

    diff --git a/PlotLine/Views/Onboarding/Index.cshtml b/PlotLine/Views/Onboarding/Index.cshtml index 26d6173..d19381e 100644 --- a/PlotLine/Views/Onboarding/Index.cshtml +++ b/PlotLine/Views/Onboarding/Index.cshtml @@ -310,8 +310,8 @@ {
    -

    Chapter review saved. PlotDirector can now detect the scene structure.

    -

    Next: analyse the approved chapters and create the detected scenes.

    +

    Ready to detect scenes.

    +

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