From 33e8f0857c5b9e09f822c7eb3aeb3670fbcf0dde Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 27 Jun 2026 19:41:44 +0100 Subject: [PATCH] Phase 16C - Word Companion First-Run Binding and Manuscript Import --- .../Controllers/WordCompanionController.cs | 21 + PlotLine/Data/WordCompanionRepository.cs | 82 +++ PlotLine/Models/WordCompanionApiModels.cs | 84 +++ PlotLine/Services/WordCompanionService.cs | 58 ++ ...5_Phase16C_WordCompanionFirstRunImport.sql | 495 +++++++++++++++ PlotLine/Views/WordCompanionHost/Index.cshtml | 49 ++ PlotLine/wwwroot/css/word-companion.css | 64 +- PlotLine/wwwroot/css/word-companion.min.css | 2 +- PlotLine/wwwroot/js/word-companion-host.js | 565 +++++++++++++++++- .../wwwroot/js/word-companion-host.min.js | 8 +- 10 files changed, 1418 insertions(+), 10 deletions(-) create mode 100644 PlotLine/Sql/095_Phase16C_WordCompanionFirstRunImport.sql diff --git a/PlotLine/Controllers/WordCompanionController.cs b/PlotLine/Controllers/WordCompanionController.cs index 3f7ae0f..ee5683a 100644 --- a/PlotLine/Controllers/WordCompanionController.cs +++ b/PlotLine/Controllers/WordCompanionController.cs @@ -20,6 +20,13 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion) return response is null ? BadRequest() : Ok(response); } + [HttpPost("projects/{projectId:int}/books")] + public async Task CreateBook(int projectId, WordCompanionCreateBookRequest request) + { + var response = await wordCompanion.CreateBookAsync(projectId, request); + return response is null ? BadRequest() : Ok(response); + } + [HttpGet("books/{bookId:int}/structure")] public async Task GetBookStructure(int bookId) { @@ -108,4 +115,18 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion) var response = await wordCompanion.LinkManuscriptAsync(request); return response is null ? BadRequest() : Ok(response); } + + [HttpPost("manuscript/analyse")] + public async Task AnalyseManuscript(WordCompanionManuscriptAnalyseRequest request) + { + var response = await wordCompanion.AnalyseManuscriptAsync(request); + return response is null ? BadRequest() : Ok(response); + } + + [HttpPost("manuscript/import")] + public async Task ImportManuscript(WordCompanionManuscriptImportRequest request) + { + var response = await wordCompanion.ImportManuscriptAsync(request); + return response is null ? BadRequest() : Ok(response); + } } diff --git a/PlotLine/Data/WordCompanionRepository.cs b/PlotLine/Data/WordCompanionRepository.cs index 5a78dd7..9355c19 100644 --- a/PlotLine/Data/WordCompanionRepository.cs +++ b/PlotLine/Data/WordCompanionRepository.cs @@ -8,6 +8,7 @@ public interface IWordCompanionRepository { Task> ListProjectsAsync(int userId); Task> ListBooksAsync(int projectId, int userId); + Task CreateBookAsync(int projectId, int userId, WordCompanionCreateBookRequest request); Task GetBookStructureAsync(int bookId, int userId); Task> ListChaptersAsync(int bookId, int userId); Task ResolveSceneAsync(int bookId, int userId, string chapterTitle, string sceneTitle); @@ -18,6 +19,8 @@ public interface IWordCompanionRepository Task SyncManuscriptAsync(int bookId, int userId, WordCompanionSyncManuscriptRequest request); Task UpdateSceneLinksAsync(int sceneId, int userId, WordCompanionUpdateSceneLinksRequest request); Task UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn); + Task AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request); + Task ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request); } public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFactory) : IWordCompanionRepository @@ -42,6 +45,15 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact return rows.ToList(); } + public async Task CreateBookAsync(int projectId, int userId, WordCompanionCreateBookRequest request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.WordCompanion_Book_Create", + new { ProjectID = projectId, UserID = userId, request.Title }, + commandType: CommandType.StoredProcedure); + } + public async Task GetBookStructureAsync(int bookId, int userId) { using var connection = connectionFactory.CreateConnection(); @@ -221,6 +233,76 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact commandType: CommandType.StoredProcedure); } + public async Task AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.WordCompanion_Manuscript_Analyse", + new + { + UserID = userId, + request.ProjectId, + request.BookId, + DocumentGuid = request.DocumentGuid, + request.ChapterCount, + request.SceneCount, + request.WordCount + }, + commandType: CommandType.StoredProcedure); + } + + public async Task ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request) + { + var chapters = request.Chapters.Select(chapter => new + { + chapter.Title, + chapter.SortOrder, + chapter.WordCount, + Scenes = chapter.Scenes.Select(scene => new + { + scene.Title, + scene.SortOrder, + scene.WordCount + }) + }); + + using var connection = connectionFactory.CreateConnection(); + using var result = await connection.QueryMultipleAsync( + "dbo.WordCompanion_Manuscript_Import", + new + { + UserID = userId, + request.ProjectId, + request.BookId, + DocumentGuid = request.DocumentGuid.GetValueOrDefault(), + request.BindingVersion, + request.ReplacePlanned, + StructureJson = System.Text.Json.JsonSerializer.Serialize(chapters) + }, + commandType: CommandType.StoredProcedure); + + var response = await result.ReadSingleOrDefaultAsync(); + if (response is null) + { + return null; + } + + var document = await result.ReadSingleOrDefaultAsync(); + return new WordCompanionManuscriptImportResponse + { + Imported = response.Imported, + RequiresReplaceConfirmation = response.RequiresReplaceConfirmation, + Message = response.Message, + ProjectId = response.ProjectId, + BookId = response.BookId, + ChaptersCreated = response.ChaptersCreated, + ScenesCreated = response.ScenesCreated, + ArchivedChapters = response.ArchivedChapters, + ArchivedScenes = response.ArchivedScenes, + ManuscriptDocument = document + }; + } + private static string ToCsv(IEnumerable ids) => string.Join(',', ids.Where(id => id > 0).Distinct()); private sealed class WordCompanionSceneStructureRow diff --git a/PlotLine/Models/WordCompanionApiModels.cs b/PlotLine/Models/WordCompanionApiModels.cs index 365af71..e8f9f5f 100644 --- a/PlotLine/Models/WordCompanionApiModels.cs +++ b/PlotLine/Models/WordCompanionApiModels.cs @@ -17,6 +17,7 @@ public sealed class WordCompanionBookDto public string Title { get; set; } = string.Empty; public string? Subtitle { get; set; } public int SortOrder { get; set; } + public bool UsesExplicitScenes { get; set; } } public sealed class WordCompanionBooksResponse @@ -249,3 +250,86 @@ public sealed class WordCompanionManuscriptLinkResponse public WordCompanionManuscriptDocumentDto ManuscriptDocument { get; init; } = new(); public string Message { get; init; } = "Manuscript linked."; } + +public sealed class WordCompanionCreateBookRequest +{ + public string? Title { get; set; } +} + +public sealed class WordCompanionCreateBookResponse +{ + public int BookId { get; init; } + public string Title { get; init; } = string.Empty; + public string? Subtitle { get; init; } + public int SortOrder { get; init; } + public bool UsesExplicitScenes { get; init; } + public string Message { get; init; } = "Book created."; +} + +public sealed class WordCompanionManuscriptImportSceneRequest +{ + public string? Title { get; set; } + public int? SortOrder { get; set; } + public int WordCount { get; set; } +} + +public sealed class WordCompanionManuscriptImportChapterRequest +{ + public string? Title { get; set; } + public int? SortOrder { get; set; } + public int WordCount { get; set; } + public IReadOnlyList Scenes { get; set; } = []; +} + +public sealed class WordCompanionManuscriptAnalyseRequest +{ + public int ProjectId { get; set; } + public int BookId { get; set; } + public Guid? DocumentGuid { get; set; } + public int ChapterCount { get; set; } + public int SceneCount { get; set; } + public int WordCount { get; set; } +} + +public sealed class WordCompanionManuscriptAnalyseResponse +{ + public int ProjectId { get; init; } + public int BookId { get; init; } + public string ProjectTitle { get; init; } = string.Empty; + public string BookTitle { get; init; } = string.Empty; + public string? BookSubtitle { get; init; } + public bool UsesExplicitScenes { get; init; } + public int ChapterCount { get; init; } + public int SceneCount { get; init; } + public int WordCount { get; init; } + public int ExistingChapterCount { get; init; } + public int ExistingSceneCount { get; init; } + public int LinkedChapterCount { get; init; } + public bool CanImport { get; init; } + public bool RequiresReplaceConfirmation { get; init; } + public string Message { get; init; } = string.Empty; +} + +public sealed class WordCompanionManuscriptImportRequest +{ + public int ProjectId { get; set; } + public int BookId { get; set; } + public Guid? DocumentGuid { get; set; } + public int BindingVersion { get; set; } = 1; + public bool ReplacePlanned { get; set; } + public IReadOnlyList Chapters { get; set; } = []; +} + +public sealed class WordCompanionManuscriptImportResponse +{ + public bool Imported { get; init; } + public bool RequiresReplaceConfirmation { get; init; } + public string Message { get; init; } = string.Empty; + public int ProjectId { get; init; } + public int BookId { get; init; } + public int ChaptersCreated { get; init; } + public int ScenesCreated { get; init; } + public int ArchivedChapters { get; init; } + public int ArchivedScenes { get; init; } + public WordCompanionManuscriptDocumentDto? ManuscriptDocument { get; init; } +} diff --git a/PlotLine/Services/WordCompanionService.cs b/PlotLine/Services/WordCompanionService.cs index 3a1f3c1..519a083 100644 --- a/PlotLine/Services/WordCompanionService.cs +++ b/PlotLine/Services/WordCompanionService.cs @@ -7,6 +7,7 @@ public interface IWordCompanionService { Task ListProjectsAsync(); Task ListBooksAsync(int projectId); + Task CreateBookAsync(int projectId, WordCompanionCreateBookRequest request); Task GetBookStructureAsync(int bookId); Task ListChaptersAsync(int bookId); Task ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request); @@ -19,6 +20,8 @@ public interface IWordCompanionService Task UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request); Task GetManuscriptForBookAsync(int bookId); Task LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request); + Task AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request); + Task ImportManuscriptAsync(WordCompanionManuscriptImportRequest request); } public sealed class WordCompanionService( @@ -43,6 +46,17 @@ public sealed class WordCompanionService( return new WordCompanionBooksResponse { Books = books }; } + public Task CreateBookAsync(int projectId, WordCompanionCreateBookRequest request) + { + if (projectId <= 0 || string.IsNullOrWhiteSpace(request.Title)) + { + return Task.FromResult(null); + } + + request.Title = request.Title.Trim(); + return repository.CreateBookAsync(projectId, RequireUserId(), request); + } + public Task GetBookStructureAsync(int bookId) { return bookId <= 0 ? Task.FromResult(null) : repository.GetBookStructureAsync(bookId, RequireUserId()); @@ -206,6 +220,50 @@ public sealed class WordCompanionService( }; } + public Task AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request) + { + if (request.ProjectId <= 0 + || request.BookId <= 0 + || request.ChapterCount < 0 + || request.SceneCount < 0 + || request.WordCount < 0) + { + return Task.FromResult(null); + } + + return repository.AnalyseManuscriptAsync(RequireUserId(), request); + } + + public Task ImportManuscriptAsync(WordCompanionManuscriptImportRequest request) + { + if (request.ProjectId <= 0 + || request.BookId <= 0 + || request.BindingVersion <= 0 + || request.Chapters.Count == 0 + || request.Chapters.Any(chapter => + string.IsNullOrWhiteSpace(chapter.Title) + || chapter.SortOrder is <= 0 + || chapter.WordCount < 0 + || chapter.Scenes.Count == 0 + || chapter.Scenes.Any(scene => string.IsNullOrWhiteSpace(scene.Title) || scene.SortOrder is <= 0 || scene.WordCount < 0))) + { + return Task.FromResult(null); + } + + request.DocumentGuid = request.DocumentGuid.GetValueOrDefault() == Guid.Empty ? Guid.NewGuid() : request.DocumentGuid; + + foreach (var chapter in request.Chapters) + { + chapter.Title = chapter.Title?.Trim(); + foreach (var scene in chapter.Scenes) + { + scene.Title = scene.Title?.Trim(); + } + } + + return repository.ImportManuscriptAsync(RequireUserId(), request); + } + private async Task ResolveSceneCoreAsync(int bookId, WordCompanionResolveSceneRequest request) { return await repository.ResolveSceneAsync(bookId, RequireUserId(), request.ChapterTitle!.Trim(), request.SceneTitle!.Trim()); diff --git a/PlotLine/Sql/095_Phase16C_WordCompanionFirstRunImport.sql b/PlotLine/Sql/095_Phase16C_WordCompanionFirstRunImport.sql new file mode 100644 index 0000000..021a2d9 --- /dev/null +++ b/PlotLine/Sql/095_Phase16C_WordCompanionFirstRunImport.sql @@ -0,0 +1,495 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.WordCompanion_Book_ListByProject + @ProjectID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT b.BookID AS BookId, + b.BookTitle AS Title, + b.Subtitle, + b.SortOrder, + b.UsesExplicitScenes + FROM dbo.Books b + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = b.ProjectID + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + WHERE b.ProjectID = @ProjectID + AND pua.UserID = @UserID + AND pua.IsActive = 1 + AND p.IsArchived = 0 + AND b.IsArchived = 0 + ORDER BY b.SortOrder, b.BookNumber, b.BookTitle, b.BookID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.WordCompanion_Book_Create + @ProjectID int, + @UserID int, + @Title nvarchar(200) +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @TrimmedTitle nvarchar(200) = LTRIM(RTRIM(COALESCE(@Title, N''))); + IF NULLIF(@TrimmedTitle, N'') IS NULL + RETURN; + + IF NOT EXISTS + ( + SELECT 1 + FROM dbo.Projects p + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE p.ProjectID = @ProjectID + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1 + ) + RETURN; + + DECLARE @NextBookNumber int = ISNULL((SELECT MAX(BookNumber) FROM dbo.Books WHERE ProjectID = @ProjectID), 0) + 1; + DECLARE @NextBookOrder int = ISNULL((SELECT MAX(SortOrder) FROM dbo.Books WHERE ProjectID = @ProjectID), 0) + 10; + DECLARE @BookID int; + + INSERT dbo.Books (ProjectID, BookTitle, Status, BookNumber, SortOrder, UsesExplicitScenes) + VALUES (@ProjectID, @TrimmedTitle, N'Planning', @NextBookNumber, @NextBookOrder, 0); + + SET @BookID = CAST(SCOPE_IDENTITY() AS int); + + SELECT @BookID AS BookId, + @TrimmedTitle AS Title, + CAST(NULL AS nvarchar(200)) AS Subtitle, + @NextBookOrder AS SortOrder, + CAST(0 AS bit) AS UsesExplicitScenes, + N'Book created.' AS Message; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.WordCompanion_Manuscript_Analyse + @UserID int, + @ProjectID int, + @BookID int, + @DocumentGuid uniqueidentifier = NULL, + @ChapterCount int, + @SceneCount int, + @WordCount int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ProjectTitle nvarchar(200); + DECLARE @BookTitle nvarchar(200); + DECLARE @BookSubtitle nvarchar(200); + DECLARE @UsesExplicitScenes bit; + + SELECT @ProjectTitle = p.ProjectName, + @BookTitle = b.BookTitle, + @BookSubtitle = b.Subtitle, + @UsesExplicitScenes = b.UsesExplicitScenes + 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; + + IF @BookTitle IS NULL + RETURN; + + DECLARE @ExistingChapterCount int = (SELECT COUNT(*) FROM dbo.Chapters WHERE BookID = @BookID AND IsArchived = 0); + DECLARE @ExistingSceneCount int = + ( + SELECT COUNT(*) + FROM dbo.Scenes s + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + WHERE c.BookID = @BookID + AND c.IsArchived = 0 + AND s.IsArchived = 0 + ); + DECLARE @LinkedChapterCount int = + ( + SELECT COUNT(*) + FROM dbo.Chapters + WHERE BookID = @BookID + AND IsArchived = 0 + AND IsLinkedToManuscript = 1 + ); + + SELECT @ProjectID AS ProjectId, + @BookID AS BookId, + @ProjectTitle AS ProjectTitle, + @BookTitle AS BookTitle, + @BookSubtitle AS BookSubtitle, + @UsesExplicitScenes AS UsesExplicitScenes, + @ChapterCount AS ChapterCount, + @SceneCount AS SceneCount, + @WordCount AS WordCount, + @ExistingChapterCount AS ExistingChapterCount, + @ExistingSceneCount AS ExistingSceneCount, + @LinkedChapterCount AS LinkedChapterCount, + CAST(CASE WHEN @LinkedChapterCount = 0 AND @ChapterCount > 0 THEN 1 ELSE 0 END AS bit) AS CanImport, + CAST(CASE WHEN @LinkedChapterCount = 0 AND @ExistingChapterCount > 0 THEN 1 ELSE 0 END AS bit) AS RequiresReplaceConfirmation, + CASE + WHEN @LinkedChapterCount > 0 THEN N'This Book already contains a linked manuscript.' + WHEN @ChapterCount = 0 THEN N'No chapters were found. Use Heading 1 for chapter titles.' + WHEN @ExistingChapterCount > 0 THEN N'This Book already contains planned chapters and scenes.' + ELSE N'Ready to import manuscript structure.' + END AS Message; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.WordCompanion_Manuscript_Import + @UserID int, + @ProjectID int, + @BookID int, + @DocumentGuid uniqueidentifier, + @BindingVersion int = 1, + @ReplacePlanned bit = 0, + @StructureJson 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 @BookTitle nvarchar(200); + DECLARE @AccessProjectID int; + + SELECT @AccessProjectID = b.ProjectID, + @BookTitle = b.BookTitle + 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; + + IF @BookTitle IS NULL + RETURN; + + DECLARE @LinkedChapterCount int = + ( + SELECT COUNT(*) + FROM dbo.Chapters + WHERE BookID = @BookID + AND IsArchived = 0 + AND IsLinkedToManuscript = 1 + ); + + IF @LinkedChapterCount > 0 + BEGIN + SELECT CAST(0 AS bit) AS Imported, + CAST(0 AS bit) AS RequiresReplaceConfirmation, + N'This Book already contains a linked manuscript.' AS Message, + @ProjectID AS ProjectId, + @BookID AS BookId, + 0 AS ChaptersCreated, + 0 AS ScenesCreated, + 0 AS ArchivedChapters, + 0 AS ArchivedScenes; + RETURN; + END; + + DECLARE @ChapterCount int = (SELECT COUNT(*) FROM dbo.Chapters WHERE BookID = @BookID AND IsArchived = 0); + IF @ChapterCount > 0 AND @ReplacePlanned = 0 + BEGIN + SELECT CAST(0 AS bit) AS Imported, + CAST(1 AS bit) AS RequiresReplaceConfirmation, + N'This Book already contains planned chapters and scenes.' AS Message, + @ProjectID AS ProjectId, + @BookID AS BookId, + 0 AS ChaptersCreated, + 0 AS ScenesCreated, + 0 AS ArchivedChapters, + 0 AS ArchivedScenes; + RETURN; + END; + + DECLARE @Chapters table + ( + ImportChapterIndex int NOT NULL PRIMARY KEY, + Title nvarchar(200) NOT NULL, + SortOrder int NOT NULL, + WordCount int NOT NULL + ); + + DECLARE @Scenes table + ( + ImportChapterIndex int NOT NULL, + ImportSceneIndex int NOT NULL, + Title nvarchar(200) NOT NULL, + SortOrder int NOT NULL, + WordCount int NOT NULL, + PRIMARY KEY (ImportChapterIndex, ImportSceneIndex) + ); + + INSERT @Chapters (ImportChapterIndex, Title, SortOrder, WordCount) + SELECT CONVERT(int, chapter.[key]) + 1, + LEFT(LTRIM(RTRIM(JSON_VALUE(chapter.value, '$.Title'))), 200), + TRY_CONVERT(int, JSON_VALUE(chapter.value, '$.SortOrder')), + COALESCE(TRY_CONVERT(int, JSON_VALUE(chapter.value, '$.WordCount')), 0) + FROM OPENJSON(@StructureJson) chapter + WHERE NULLIF(LTRIM(RTRIM(JSON_VALUE(chapter.value, '$.Title'))), N'') IS NOT NULL; + + INSERT @Scenes (ImportChapterIndex, ImportSceneIndex, Title, SortOrder, WordCount) + SELECT c.ImportChapterIndex, + CONVERT(int, scene.[key]) + 1, + LEFT(LTRIM(RTRIM(JSON_VALUE(scene.value, '$.Title'))), 200), + TRY_CONVERT(int, JSON_VALUE(scene.value, '$.SortOrder')), + COALESCE(TRY_CONVERT(int, JSON_VALUE(scene.value, '$.WordCount')), 0) + FROM @Chapters c + INNER JOIN OPENJSON(@StructureJson) chapter ON CONVERT(int, chapter.[key]) + 1 = c.ImportChapterIndex + CROSS APPLY OPENJSON(chapter.value, '$.Scenes') scene + WHERE NULLIF(LTRIM(RTRIM(JSON_VALUE(scene.value, '$.Title'))), N'') IS NOT NULL; + + IF NOT EXISTS (SELECT 1 FROM @Chapters) + 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; + + IF EXISTS (SELECT 1 FROM @Chapters c WHERE NOT EXISTS (SELECT 1 FROM @Scenes s WHERE s.ImportChapterIndex = c.ImportChapterIndex)) + 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); + + IF @PlannedRevisionStatusID IS NULL OR @DefaultTimeModeID IS NULL OR @DefaultTimeConfidenceID IS NULL + RETURN; + + DECLARE @ImportChapterMap table + ( + ImportChapterIndex int NOT NULL PRIMARY KEY, + ChapterID int NOT NULL + ); + + DECLARE @ImportSceneMap table + ( + ImportChapterIndex int NOT NULL, + ImportSceneIndex int NOT NULL, + SceneID int NOT NULL, + PRIMARY KEY (ImportChapterIndex, ImportSceneIndex) + ); + + DECLARE @ArchivedChapters int = 0; + DECLARE @ArchivedScenes int = 0; + DECLARE @ManuscriptDocumentID int; + DECLARE @LinkedUtc datetime2 = SYSUTCDATETIME(); + + BEGIN TRANSACTION; + + IF @ChapterCount > 0 + BEGIN + UPDATE s + SET IsArchived = 1, + ArchivedDate = SYSUTCDATETIME(), + ArchivedReason = N'Replaced by Word Companion manuscript import', + UpdatedDate = SYSUTCDATETIME() + FROM dbo.Scenes s + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + WHERE c.BookID = @BookID + AND c.IsArchived = 0 + AND s.IsArchived = 0 + AND c.IsLinkedToManuscript = 0 + AND s.IsLinkedToManuscript = 0; + + SET @ArchivedScenes = @@ROWCOUNT; + + UPDATE dbo.Chapters + SET IsArchived = 1, + ArchivedDate = SYSUTCDATETIME(), + ArchivedReason = N'Replaced by Word Companion manuscript import', + UpdatedDate = SYSUTCDATETIME() + WHERE BookID = @BookID + AND IsArchived = 0 + AND IsLinkedToManuscript = 0; + + SET @ArchivedChapters = @@ROWCOUNT; + END; + + 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; + + DECLARE @ImportChapterIndex int; + DECLARE @ChapterTitle nvarchar(200); + DECLARE @ChapterSortOrder int; + DECLARE @ChapterID int; + DECLARE @ChapterNumber decimal(9,2); + + DECLARE chapter_cursor CURSOR LOCAL FAST_FORWARD FOR + SELECT ImportChapterIndex, Title, SortOrder + FROM @Chapters + ORDER BY SortOrder, ImportChapterIndex; + + OPEN chapter_cursor; + FETCH NEXT FROM chapter_cursor INTO @ImportChapterIndex, @ChapterTitle, @ChapterSortOrder; + + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @ChapterNumber = @ImportChapterIndex; + + INSERT dbo.Chapters + ( + BookID, ChapterNumber, ChapterTitle, Summary, SortOrder, RevisionStatusID, + IsLinkedToManuscript, ManuscriptDocumentID, ExternalReference, LinkedUtc + ) + VALUES + ( + @BookID, @ChapterNumber, @ChapterTitle, NULL, @ChapterSortOrder, @PlannedRevisionStatusID, + 1, @ManuscriptDocumentID, CONCAT(N'PD_CHAPTER_IMPORT_', @ImportChapterIndex), @LinkedUtc + ); + + SET @ChapterID = CAST(SCOPE_IDENTITY() AS int); + INSERT @ImportChapterMap (ImportChapterIndex, ChapterID) VALUES (@ImportChapterIndex, @ChapterID); + + FETCH NEXT FROM chapter_cursor INTO @ImportChapterIndex, @ChapterTitle, @ChapterSortOrder; + END; + + CLOSE chapter_cursor; + DEALLOCATE chapter_cursor; + + DECLARE @ImportSceneIndex int; + DECLARE @SceneTitle nvarchar(200); + DECLARE @SceneSortOrder int; + DECLARE @SceneWordCount int; + DECLARE @SceneID int; + + DECLARE scene_cursor CURSOR LOCAL FAST_FORWARD FOR + SELECT s.ImportChapterIndex, s.ImportSceneIndex, s.Title, s.SortOrder, s.WordCount, m.ChapterID + FROM @Scenes s + INNER JOIN @ImportChapterMap m ON m.ImportChapterIndex = s.ImportChapterIndex + ORDER BY s.ImportChapterIndex, s.SortOrder, s.ImportSceneIndex; + + OPEN scene_cursor; + FETCH NEXT FROM scene_cursor INTO @ImportChapterIndex, @ImportSceneIndex, @SceneTitle, @SceneSortOrder, @SceneWordCount, @ChapterID; + + WHILE @@FETCH_STATUS = 0 + BEGIN + IF @ImportSceneIndex = 1 + BEGIN + SELECT TOP (1) @SceneID = SceneID + FROM dbo.Scenes + WHERE ChapterID = @ChapterID + AND IsArchived = 0 + ORDER BY SortOrder, SceneID; + + UPDATE dbo.Scenes + SET SceneNumber = @ImportSceneIndex, + SceneTitle = @SceneTitle, + SortOrder = @SceneSortOrder, + IsLinkedToManuscript = 1, + ManuscriptDocumentID = @ManuscriptDocumentID, + ExternalReference = CONCAT(N'PD_SCENE_IMPORT_', @ImportChapterIndex, N'_', @ImportSceneIndex), + 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, @ImportSceneIndex, @SceneTitle, NULL, @SceneSortOrder, @DefaultTimeModeID, @DefaultTimeConfidenceID, @PlannedRevisionStatusID, + 1, @ManuscriptDocumentID, CONCAT(N'PD_SCENE_IMPORT_', @ImportChapterIndex, N'_', @ImportSceneIndex), @LinkedUtc + ); + + SET @SceneID = CAST(SCOPE_IDENTITY() AS int); + END; + + IF EXISTS (SELECT 1 FROM dbo.SceneWorkflow WHERE SceneID = @SceneID) + BEGIN + UPDATE dbo.SceneWorkflow + SET ActualWordCount = @SceneWordCount, + UpdatedDate = SYSUTCDATETIME() + WHERE SceneID = @SceneID; + END + ELSE + BEGIN + INSERT dbo.SceneWorkflow (SceneID, ActualWordCount) + VALUES (@SceneID, @SceneWordCount); + END; + + INSERT @ImportSceneMap (ImportChapterIndex, ImportSceneIndex, SceneID) + VALUES (@ImportChapterIndex, @ImportSceneIndex, @SceneID); + + FETCH NEXT FROM scene_cursor INTO @ImportChapterIndex, @ImportSceneIndex, @SceneTitle, @SceneSortOrder, @SceneWordCount, @ChapterID; + END; + + CLOSE scene_cursor; + DEALLOCATE scene_cursor; + + COMMIT TRANSACTION; + + SELECT CAST(1 AS bit) AS Imported, + CAST(0 AS bit) AS RequiresReplaceConfirmation, + N'Manuscript structure imported.' AS Message, + @ProjectID AS ProjectId, + @BookID AS BookId, + (SELECT COUNT(*) FROM @ImportChapterMap) AS ChaptersCreated, + (SELECT COUNT(*) FROM @ImportSceneMap) AS ScenesCreated, + @ArchivedChapters AS ArchivedChapters, + @ArchivedScenes AS ArchivedScenes; + + SELECT md.ManuscriptDocumentID AS ManuscriptDocumentId, + md.BookID AS BookId, + md.ProjectID AS ProjectId, + md.DocumentGuid, + md.BindingVersion, + md.CreatedUtc, + md.LastSyncUtc, + md.LastOpenedUtc, + md.IsActive + FROM dbo.ManuscriptDocuments md + WHERE md.ManuscriptDocumentID = @ManuscriptDocumentID; +END; +GO diff --git a/PlotLine/Views/WordCompanionHost/Index.cshtml b/PlotLine/Views/WordCompanionHost/Index.cshtml index 9472c71..15d0232 100644 --- a/PlotLine/Views/WordCompanionHost/Index.cshtml +++ b/PlotLine/Views/WordCompanionHost/Index.cshtml @@ -42,6 +42,41 @@ } else { + +