From ffcbe99028c3bba755cd231cecef23850cc8d466 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 14 Jun 2026 10:04:41 +0100 Subject: [PATCH] Phase 10K chapter create/link foundation. --- .../Controllers/WordCompanionController.cs | 14 + PlotLine/Data/WordCompanionRepository.cs | 21 + PlotLine/Models/WordCompanionApiModels.cs | 26 ++ PlotLine/Services/WordCompanionService.cs | 24 + ...hase10K_WordCompanionChapterCreateLink.sql | 82 ++++ PlotLine/Views/WordCompanionHost/Index.cshtml | 47 ++ PlotLine/wwwroot/js/word-companion-host.js | 432 +++++++++++++++++- 7 files changed, 635 insertions(+), 11 deletions(-) create mode 100644 PlotLine/Sql/064_Phase10K_WordCompanionChapterCreateLink.sql diff --git a/PlotLine/Controllers/WordCompanionController.cs b/PlotLine/Controllers/WordCompanionController.cs index acbfab7..f7685db 100644 --- a/PlotLine/Controllers/WordCompanionController.cs +++ b/PlotLine/Controllers/WordCompanionController.cs @@ -27,6 +27,13 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion) return response is null ? NotFound() : Ok(response); } + [HttpGet("books/{bookId:int}/chapters")] + public async Task GetChapters(int bookId) + { + var response = await wordCompanion.ListChaptersAsync(bookId); + return response is null ? BadRequest() : Ok(response); + } + [HttpPost("books/{bookId:int}/resolve-scene")] public async Task ResolveScene(int bookId, WordCompanionResolveSceneRequest request) { @@ -46,6 +53,13 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion) return response is null ? NotFound() : Ok(response); } + [HttpPost("books/{bookId:int}/chapters")] + public async Task CreateChapter(int bookId, WordCompanionCreateChapterRequest request) + { + var response = await wordCompanion.CreateChapterAsync(bookId, request); + return response is null ? BadRequest() : Ok(response); + } + [HttpPost("books/{bookId:int}/chapters/{chapterId:int}/scenes")] public async Task CreateScene(int bookId, int chapterId, WordCompanionCreateSceneRequest request) { diff --git a/PlotLine/Data/WordCompanionRepository.cs b/PlotLine/Data/WordCompanionRepository.cs index 60e1338..5a78dd7 100644 --- a/PlotLine/Data/WordCompanionRepository.cs +++ b/PlotLine/Data/WordCompanionRepository.cs @@ -9,8 +9,10 @@ public interface IWordCompanionRepository Task> ListProjectsAsync(int userId); Task> ListBooksAsync(int projectId, int userId); Task GetBookStructureAsync(int bookId, int userId); + Task> ListChaptersAsync(int bookId, int userId); Task ResolveSceneAsync(int bookId, int userId, string chapterTitle, string sceneTitle); Task GetSceneCompanionAsync(int sceneId, int userId); + Task CreateChapterAsync(int bookId, int userId, WordCompanionCreateChapterRequest request); Task CreateSceneAsync(int bookId, int chapterId, int userId, WordCompanionCreateSceneRequest request); Task SyncSceneAsync(int bookId, int userId, WordCompanionSyncSceneRequest request); Task SyncManuscriptAsync(int bookId, int userId, WordCompanionSyncManuscriptRequest request); @@ -74,6 +76,16 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact return new WordCompanionBookStructureResponse { BookId = header.BookId, Chapters = chapters }; } + public async Task> ListChaptersAsync(int bookId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.WordCompanion_Chapter_List", + new { BookID = bookId, UserID = userId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + public async Task ResolveSceneAsync(int bookId, int userId, string chapterTitle, string sceneTitle) { using var connection = connectionFactory.CreateConnection(); @@ -116,6 +128,15 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact }; } + public async Task CreateChapterAsync(int bookId, int userId, WordCompanionCreateChapterRequest request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.WordCompanion_Chapter_Create", + new { BookID = bookId, UserID = userId, request.Title, request.SortOrder }, + commandType: CommandType.StoredProcedure); + } + public async Task CreateSceneAsync(int bookId, int chapterId, int userId, WordCompanionCreateSceneRequest request) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/WordCompanionApiModels.cs b/PlotLine/Models/WordCompanionApiModels.cs index 6ad3cba..316544e 100644 --- a/PlotLine/Models/WordCompanionApiModels.cs +++ b/PlotLine/Models/WordCompanionApiModels.cs @@ -47,6 +47,18 @@ public sealed class WordCompanionBookStructureResponse public IReadOnlyList Chapters { get; init; } = []; } +public sealed class WordCompanionChaptersResponse +{ + public IReadOnlyList Chapters { get; init; } = []; +} + +public sealed class WordCompanionChapterDto +{ + public int ChapterId { get; init; } + public string Title { get; init; } = string.Empty; + public int SortOrder { get; init; } +} + public sealed class WordCompanionResolveSceneRequest { public string? ChapterTitle { get; set; } @@ -127,6 +139,20 @@ public sealed class WordCompanionCreateSceneResponse public string Message { get; init; } = "Scene created."; } +public sealed class WordCompanionCreateChapterRequest +{ + public string? Title { get; set; } + public int? SortOrder { get; set; } +} + +public sealed class WordCompanionCreateChapterResponse +{ + public int ChapterId { get; init; } + public string Title { get; init; } = string.Empty; + public int SortOrder { get; init; } + public string Message { get; init; } = "Chapter created successfully."; +} + public sealed class WordCompanionSyncManuscriptRequest { public string? DocumentId { get; set; } diff --git a/PlotLine/Services/WordCompanionService.cs b/PlotLine/Services/WordCompanionService.cs index d032ebf..a0b2cdc 100644 --- a/PlotLine/Services/WordCompanionService.cs +++ b/PlotLine/Services/WordCompanionService.cs @@ -8,8 +8,10 @@ public interface IWordCompanionService Task ListProjectsAsync(); Task ListBooksAsync(int projectId); Task GetBookStructureAsync(int bookId); + Task ListChaptersAsync(int bookId); Task ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request); Task GetSceneCompanionAsync(int sceneId); + Task CreateChapterAsync(int bookId, WordCompanionCreateChapterRequest request); Task CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request); Task SyncSceneAsync(int bookId, WordCompanionSyncSceneRequest request); Task SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request); @@ -41,6 +43,17 @@ public sealed class WordCompanionService(IWordCompanionRepository repository, IC return bookId <= 0 ? Task.FromResult(null) : repository.GetBookStructureAsync(bookId, RequireUserId()); } + public async Task ListChaptersAsync(int bookId) + { + if (bookId <= 0) + { + return null; + } + + var chapters = await repository.ListChaptersAsync(bookId, RequireUserId()); + return new WordCompanionChaptersResponse { Chapters = chapters }; + } + public Task ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request) { if (bookId <= 0 || string.IsNullOrWhiteSpace(request.ChapterTitle) || string.IsNullOrWhiteSpace(request.SceneTitle)) @@ -56,6 +69,17 @@ public sealed class WordCompanionService(IWordCompanionRepository repository, IC return sceneId <= 0 ? Task.FromResult(null) : repository.GetSceneCompanionAsync(sceneId, RequireUserId()); } + public Task CreateChapterAsync(int bookId, WordCompanionCreateChapterRequest request) + { + if (bookId <= 0 || string.IsNullOrWhiteSpace(request.Title) || request.SortOrder is <= 0) + { + return Task.FromResult(null); + } + + request.Title = request.Title.Trim(); + return repository.CreateChapterAsync(bookId, RequireUserId(), request); + } + public Task CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request) { if (bookId <= 0 || chapterId <= 0 || string.IsNullOrWhiteSpace(request.SceneTitle)) diff --git a/PlotLine/Sql/064_Phase10K_WordCompanionChapterCreateLink.sql b/PlotLine/Sql/064_Phase10K_WordCompanionChapterCreateLink.sql new file mode 100644 index 0000000..76cfc5b --- /dev/null +++ b/PlotLine/Sql/064_Phase10K_WordCompanionChapterCreateLink.sql @@ -0,0 +1,82 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.WordCompanion_Chapter_List + @BookID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + 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.IsArchived = 0 + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1 + ) + RETURN; + + SELECT c.ChapterID AS ChapterId, + c.ChapterTitle AS Title, + c.SortOrder + FROM dbo.Chapters c + WHERE c.BookID = @BookID + AND c.IsArchived = 0 + ORDER BY c.SortOrder, c.ChapterNumber, c.ChapterID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.WordCompanion_Chapter_Create + @BookID int, + @UserID int, + @Title nvarchar(200), + @SortOrder int = NULL +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @TrimmedTitle nvarchar(200) = LTRIM(RTRIM(COALESCE(@Title, N''))); + IF NULLIF(@TrimmedTitle, N'') IS NULL OR @SortOrder <= 0 + RETURN; + + 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.IsArchived = 0 + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1 + ) + RETURN; + + DECLARE @PlannedRevisionStatusID int = (SELECT TOP (1) RevisionStatusID FROM dbo.RevisionStatuses WHERE StatusName = N'Planned' ORDER BY SortOrder); + IF @PlannedRevisionStatusID IS NULL + RETURN; + + DECLARE @NextChapterNumber decimal(9,2) = ISNULL((SELECT MAX(ChapterNumber) FROM dbo.Chapters WHERE BookID = @BookID), 0) + 1; + DECLARE @NextChapterOrder int = ISNULL((SELECT MAX(SortOrder) FROM dbo.Chapters WHERE BookID = @BookID), 0) + 10; + DECLARE @ChapterID int; + + INSERT dbo.Chapters (BookID, ChapterNumber, ChapterTitle, Summary, SortOrder, RevisionStatusID) + VALUES (@BookID, @NextChapterNumber, @TrimmedTitle, NULL, COALESCE(@SortOrder, @NextChapterOrder), @PlannedRevisionStatusID); + + SET @ChapterID = CAST(SCOPE_IDENTITY() AS int); + + SELECT @ChapterID AS ChapterId, + @TrimmedTitle AS Title, + COALESCE(@SortOrder, @NextChapterOrder) AS SortOrder, + N'Chapter created successfully.' AS Message; +END; +GO diff --git a/PlotLine/Views/WordCompanionHost/Index.cshtml b/PlotLine/Views/WordCompanionHost/Index.cshtml index 3beeed4..0cbd8ba 100644 --- a/PlotLine/Views/WordCompanionHost/Index.cshtml +++ b/PlotLine/Views/WordCompanionHost/Index.cshtml @@ -121,6 +121,23 @@

+ +