diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 6b49604..e55cf85 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -71,6 +71,7 @@ var tests = new (string Name, Action Test)[] ("Lean import removes replay links while retaining progress", LeanImportRemovesReplayLinksWhileRetainingProgress), ("Story Intelligence progress does not prematurely report safe close", StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose), ("Structural import creates scenes before Word sync", StructuralImportCreatesScenesBeforeWordSync), + ("Structural import creates first scene without placeholder", StructuralImportCreatesFirstSceneWithoutPlaceholder), ("Structural import persists paragraph mapping and source text", StructuralImportPersistsParagraphMappingAndSourceText), ("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText), ("Structural import adds summaries without extra scene AI calls", StructuralImportAddsSummariesWithoutExtraSceneAiCalls), @@ -1727,6 +1728,18 @@ static void StructuralImportCreatesScenesBeforeWordSync() Assert(onboarding.Contains("BuildSceneMarkerInstructions(structuralScenes, result.SceneMappings)", StringComparison.Ordinal), "Permanent SceneIDs should exist before Word marker instructions are built."); } +static void StructuralImportCreatesFirstSceneWithoutPlaceholder() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var migration = File.ReadAllText(Path.Combine(root, "Sql/157_Phase25B_OnboardingBuildFirstSceneInsert.sql")); + + Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_Create", StringComparison.Ordinal), "Forward migration should update the onboarding build stored procedure."); + Assert(migration.Contains("IF @SceneID IS NOT NULL", StringComparison.Ordinal), "First scene handling should only update an existing placeholder when one exists."); + Assert(migration.Contains("ELSE\r\n BEGIN\r\n INSERT dbo.Scenes", StringComparison.Ordinal) + || migration.Contains("ELSE\n BEGIN\n INSERT dbo.Scenes", StringComparison.Ordinal), "First scene handling should insert a scene when no placeholder exists."); + Assert(migration.Contains("SET @SceneID = NULL;", StringComparison.Ordinal), "Scene cursor should clear SceneID between rows."); +} + static void StructuralImportPersistsParagraphMappingAndSourceText() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); diff --git a/PlotLine/Sql/157_Phase25B_OnboardingBuildFirstSceneInsert.sql b/PlotLine/Sql/157_Phase25B_OnboardingBuildFirstSceneInsert.sql new file mode 100644 index 0000000..bd000a1 --- /dev/null +++ b/PlotLine/Sql/157_Phase25B_OnboardingBuildFirstSceneInsert.sql @@ -0,0 +1,235 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +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 TemporarySceneKey, SceneID FROM dbo.OnboardingManuscriptBuildSceneMap WHERE OnboardingManuscriptBuildID = @ExistingBuildID ORDER BY 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, 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, SortOrder, WordCount) + SELECT LEFT(JSON_VALUE(value, '$.TemporarySceneKey'), 80), + 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(@ScenesJson) + WHERE NULLIF(JSON_VALUE(value, '$.TemporarySceneKey'), N'') IS NOT NULL + AND NULLIF(JSON_VALUE(value, '$.TemporaryChapterKey'), N'') IS NOT NULL + AND NULLIF(LTRIM(RTRIM(JSON_VALUE(value, '$.Title'))), 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); + DECLARE scene_cursor CURSOR LOCAL FAST_FORWARD FOR + SELECT TemporarySceneKey, TemporaryChapterKey, Title, SortOrder, WordCount FROM @Scenes ORDER BY TemporaryChapterKey, SortOrder, TemporarySceneKey; + OPEN scene_cursor; + FETCH NEXT FROM scene_cursor INTO @TempSceneKey, @TempSceneChapterKey, @Title, @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); + 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 = @Title, SortOrder = @SortOrder, IsLinkedToManuscript = 1, + ManuscriptDocumentID = @ManuscriptDocumentID, ExternalReference = @TempSceneKey, LinkedUtc = @LinkedUtc, UpdatedDate = SYSUTCDATETIME() + WHERE SceneID = @SceneID; + END + ELSE + BEGIN + INSERT dbo.Scenes (ChapterID, SceneNumber, SceneTitle, Summary, SortOrder, TimeModeID, TimeConfidenceID, RevisionStatusID, IsLinkedToManuscript, ManuscriptDocumentID, ExternalReference, LinkedUtc) + VALUES (@ChapterID, @SceneNumber, @Title, 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, Summary, SortOrder, TimeModeID, TimeConfidenceID, RevisionStatusID, IsLinkedToManuscript, ManuscriptDocumentID, ExternalReference, LinkedUtc) + VALUES (@ChapterID, @SceneNumber, @Title, 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, @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 TemporarySceneKey, SceneID FROM dbo.OnboardingManuscriptBuildSceneMap WHERE OnboardingManuscriptBuildID = @BuildID ORDER BY TemporarySceneKey; + SELECT TemporaryCharacterKey, CharacterID, Created FROM dbo.OnboardingManuscriptBuildCharacterMap WHERE OnboardingManuscriptBuildID = @BuildID ORDER BY TemporaryCharacterKey; +END; +GO