Unify core import wizard and scene titles
This commit is contained in:
parent
c10745bcc4
commit
358913f0ef
@ -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(
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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
|
||||
|
||||
34
PlotLine/Models/SceneTitleDisplay.cs
Normal file
34
PlotLine/Models/SceneTitleDisplay.cs
Normal 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);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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),
|
||||
|
||||
360
PlotLine/Sql/162_Phase25G_CoreImportSceneTitles.sql
Normal file
360
PlotLine/Sql/162_Phase25G_CoreImportSceneTitles.sql
Normal 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
|
||||
@ -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; } = [];
|
||||
|
||||
@ -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 · @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.
|
||||
}
|
||||
</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") ·
|
||||
@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")
|
||||
}
|
||||
</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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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") ·
|
||||
@Model.SelectedSceneCount.ToString("N0") scene@(Model.SelectedSceneCount == 1 ? string.Empty : "s") ·
|
||||
@visibleCharacterCount.ToString("N0") character@(visibleCharacterCount == 1 ? string.Empty : "s") ·
|
||||
@Model.SelectedWordCount.ToString("N0") words
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="onboarding-compact-status">
|
||||
@Model.SelectedChapterCount.ToString("N0") of @Model.ChapterCount.ToString("N0") chapters selected ·
|
||||
@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>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user