Unify core import wizard and scene titles

This commit is contained in:
Nick Beckley 2026-08-27 15:27:12 +00:00
parent c10745bcc4
commit 358913f0ef
17 changed files with 585 additions and 57 deletions

View File

@ -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("<span>Scenes</span>", 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("<h2>Chapters</h2>", 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(

View File

@ -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; }

View File

@ -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."

View File

@ -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; }

View File

@ -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; }

View File

@ -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<ManuscriptScanChapterReviewDecision> Chapters { get; init; } = [];
public IReadOnlyList<ManuscriptScanSceneReviewDecision> 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

View File

@ -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);
}

View File

@ -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,

View File

@ -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)

View File

@ -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; }

View File

@ -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<OnboardingSceneStructureSummary> BuildSceneStructureSummary(
IReadOnlyList<ManuscriptScanChapterReviewDecision> chapters,
IReadOnlyList<ManuscriptStructureSceneBoundary> scenes)
IReadOnlyList<ManuscriptStructureSceneBoundary> 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),

View File

@ -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

View File

@ -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<ManuscriptScanChapterReviewFormItem> Chapters { get; set; } = [];
public List<ManuscriptScanSceneReviewFormItem> Scenes { get; set; } = [];
public List<ManuscriptScanCharacterReviewFormItem> Characters { get; set; } = [];

View File

@ -16,7 +16,7 @@
<p>
@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 &middot; @Model.ScenesCreated.ToString("N0") scenes &middot; @((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.
}
</p>
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
@ -33,24 +33,14 @@
}
</div>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Chapters</span>
<strong>@Model.ChaptersCreated</strong>
</div>
<div>
<span>Scenes</span>
<strong>@Model.ScenesCreated</strong>
</div>
<div>
<span>Characters created now</span>
<strong>@(Model.CharactersCreated + Model.CharactersReused)</strong>
</div>
<div>
<span>Scene appearances now</span>
<strong>@Model.SceneAppearancesCreated</strong>
</div>
</div>
<p class="onboarding-compact-status">
@Model.ChaptersCreated.ToString("N0") chapter@(Model.ChaptersCreated == 1 ? string.Empty : "s") &middot;
@Model.ScenesCreated.ToString("N0") scene@(Model.ScenesCreated == 1 ? string.Empty : "s")
@if (coreComplete)
{
@:&middot; @((Model.CharactersCreated + Model.CharactersReused).ToString("N0")) character@((Model.CharactersCreated + Model.CharactersReused) == 1 ? string.Empty : "s")
}
</p>
@if (Model.SceneStructure.Any())
{
@ -71,8 +61,8 @@
@foreach (var scene in chapter.Scenes)
{
<li>
<strong>@scene.Title</strong>
<span>@scene.WordCount.ToString("N0") words / paragraphs @scene.StartParagraph-@scene.EndParagraph</span>
<strong>@scene.DisplayTitle</strong>
<span>@scene.WordCount.ToString("N0") words</span>
@if (!string.IsNullOrWhiteSpace(scene.StructuralSummary))
{
<p>@scene.StructuralSummary</p>

View File

@ -310,8 +310,8 @@
{
<div class="onboarding-build-panel" data-onboarding-build-panel>
<div>
<p class="onboarding-scan-next">Chapter review saved. PlotDirector can now detect the scene structure.</p>
<p class="onboarding-build-message" data-onboarding-build-message>Next: analyse the approved chapters and create the detected scenes.</p>
<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">

View File

@ -55,9 +55,6 @@
&& !includedPrimaryKeys.Contains(candidate.AliasTargetTemporaryCharacterKey)))
.Select(candidate => candidate.TemporaryCharacterKey)
.ToHashSet(StringComparer.Ordinal);
var displayedSceneCount = isCharacterStep ? Model.SelectedSceneCount : 0;
var displayedCharacterCount = isCharacterStep ? Model.SelectedCharacterCount : 0;
var displayedCharacterTotal = isCharacterStep ? visibleCharacterCount : 0;
}
<section class="onboarding-shell" aria-labelledby="scan-review-title">
@ -66,6 +63,10 @@
<input type="hidden" asp-for="PreviewID" name="PreviewID" />
<input type="hidden" name="characterPage" value="@characterPage" />
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = currentStep, TotalSteps = totalSteps })" />
@if (isCharacterStep)
{
<input type="hidden" name="GenerateSceneTitles" value="@Model.GenerateSceneTitles.ToString().ToLowerInvariant()" />
}
<div class="onboarding-copy">
<p class="eyebrow">Step @currentStep of @totalSteps</p>
@ -85,27 +86,33 @@
</div>
}
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Chapters</span>
<strong>@Model.SelectedChapterCount / @Model.ChapterCount</strong>
</div>
<div>
<span>Scenes</span>
<strong>@displayedSceneCount.ToString("N0")</strong>
</div>
<div>
<span>Characters</span>
<strong>@displayedCharacterCount.ToString("N0") / @displayedCharacterTotal.ToString("N0")</strong>
</div>
<div>
<span>Words</span>
<strong>@Model.SelectedWordCount.ToString("N0")</strong>
</div>
</div>
@if (isCharacterStep)
{
<p class="onboarding-compact-status">
@Model.SelectedChapterCount.ToString("N0") chapter@(Model.SelectedChapterCount == 1 ? string.Empty : "s") &middot;
@Model.SelectedSceneCount.ToString("N0") scene@(Model.SelectedSceneCount == 1 ? string.Empty : "s") &middot;
@visibleCharacterCount.ToString("N0") character@(visibleCharacterCount == 1 ? string.Empty : "s") &middot;
@Model.SelectedWordCount.ToString("N0") words
</p>
}
else
{
<p class="onboarding-compact-status">
@Model.SelectedChapterCount.ToString("N0") of @Model.ChapterCount.ToString("N0") chapters selected &middot;
@Model.SelectedWordCount.ToString("N0") words
</p>
}
@if (!isCharacterStep)
{
<label class="onboarding-inline-choice">
<input type="checkbox" name="GenerateSceneTitles" value="true" checked="@Model.GenerateSceneTitles" />
<input type="hidden" name="GenerateSceneTitles" value="false" />
<span>
<strong>Generate descriptive scene titles</strong>
<small>Titles are suggested during scene detection and can be edited later. Word markers stay unchanged.</small>
</span>
</label>
<div class="onboarding-review-tools" data-review-tools>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="chapter" data-bulk-state="true">Include all chapters</button>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="chapter" data-bulk-state="false">Exclude all chapters</button>

View File

@ -479,6 +479,40 @@
margin-bottom: 1.25rem;
}
.onboarding-compact-status {
display: inline-flex;
flex-wrap: wrap;
gap: .35rem;
margin: 0 0 1rem;
color: var(--bs-secondary-color);
font-weight: 700;
}
.onboarding-inline-choice {
display: flex;
align-items: flex-start;
gap: .7rem;
margin: 0 0 1rem;
padding: .8rem .9rem;
border: 1px solid rgba(31, 42, 68, .12);
border-radius: 8px;
background: rgba(255, 255, 255, .58);
}
.onboarding-inline-choice input {
flex: 0 0 auto;
margin-top: .2rem;
}
.onboarding-inline-choice span {
display: grid;
gap: .15rem;
}
.onboarding-inline-choice small {
color: var(--bs-secondary-color);
}
.onboarding-review-panel .onboarding-review-counts {
position: sticky;
top: .75rem;