using System.Data; using Dapper; using PlotLine.Models; namespace PlotLine.Data; 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); 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); Task UpdateSceneLinksAsync(int sceneId, int userId, WordCompanionUpdateSceneLinksRequest request); Task UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn); Task UpdateRuntimeSceneWordCountAsync(int userId, WordCompanionRuntimeSceneWordCountRequest request); Task ValidateRuntimeCurrentSceneAsync(int userId, WordCompanionRuntimeCurrentSceneRequest request); Task> ListRuntimeCharacterAliasesAsync(int userId, int projectId, Guid documentGuid); Task CreateRuntimeCharacterSuggestionsAsync(int userId, WordCompanionRuntimeCharacterSuggestionsRequest request); Task> ListRuntimeAssetAliasesAsync(int userId, int projectId, Guid documentGuid); Task CreateRuntimeAssetSuggestionsAsync(int userId, WordCompanionRuntimeAssetSuggestionsRequest request); Task> ListRuntimeLocationAliasesAsync(int userId, int projectId, Guid documentGuid); Task CreateRuntimeLocationSuggestionsAsync(int userId, WordCompanionRuntimeLocationSuggestionsRequest request); Task AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request); Task ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request); Task HasProjectAccessAsync(int projectId, int userId); Task CreateDiscoveredCharactersAsync(int userId, WordCompanionCreateDiscoveredCharactersRequest request); } public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFactory) : IWordCompanionRepository { public async Task> ListProjectsAsync(int userId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.WordCompanion_Project_List", new { UserID = userId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListBooksAsync(int projectId, int userId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.WordCompanion_Book_ListByProject", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); 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(); using var result = await connection.QueryMultipleAsync( "dbo.WordCompanion_Book_Structure", new { BookID = bookId, UserID = userId }, commandType: CommandType.StoredProcedure); var header = await result.ReadSingleOrDefaultAsync(); if (header is null) { return null; } var chapters = (await result.ReadAsync()).ToList(); var scenes = (await result.ReadAsync()).ToList(); var scenesByChapter = scenes.GroupBy(x => x.ChapterId).ToDictionary(x => x.Key, x => x.Select(scene => new WordCompanionSceneStructureDto { SceneId = scene.SceneId, Title = scene.Title, SortOrder = scene.SortOrder, RevisionStatus = scene.RevisionStatus, EstimatedWords = scene.EstimatedWords, ActualWords = scene.ActualWords }).ToList()); foreach (var chapter in chapters) { chapter.Scenes = scenesByChapter.TryGetValue(chapter.ChapterId, out var chapterScenes) ? chapterScenes : []; } 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(); var response = await connection.QuerySingleAsync( "dbo.WordCompanion_Scene_Resolve", new { BookID = bookId, UserID = userId, ChapterTitle = chapterTitle, SceneTitle = sceneTitle }, commandType: CommandType.StoredProcedure); return response; } public async Task GetSceneCompanionAsync(int sceneId, int userId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync( "dbo.WordCompanion_Scene_Companion", new { SceneID = sceneId, UserID = userId }, commandType: CommandType.StoredProcedure); var scene = await result.ReadSingleOrDefaultAsync(); if (scene is null) { return null; } return new WordCompanionSceneCompanionResponse { SceneId = scene.SceneId, ChapterId = scene.ChapterId, ChapterTitle = scene.ChapterTitle, SceneTitle = scene.SceneTitle, ActualWords = scene.ActualWords, EstimatedWords = scene.EstimatedWords, WritingBrief = scene.WritingBrief, Blocked = scene.Blocked, BlockedReason = scene.BlockedReason, PrimaryLocationId = scene.PrimaryLocationId, Characters = (await result.ReadAsync()).ToList(), Assets = (await result.ReadAsync()).ToList(), Locations = (await result.ReadAsync()).ToList() }; } 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(); return await connection.QuerySingleOrDefaultAsync( "dbo.WordCompanion_Scene_Create", new { BookID = bookId, ChapterID = chapterId, UserID = userId, request.SceneTitle }, commandType: CommandType.StoredProcedure); } public async Task SyncSceneAsync(int bookId, int userId, WordCompanionSyncSceneRequest request) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.WordCompanion_Scene_Sync", new { BookID = bookId, UserID = userId, request.ChapterTitle, request.ChapterSortOrder, request.SceneTitle, request.SceneSortOrder, request.ActualWords, request.DocumentId, request.ContentHash }, commandType: CommandType.StoredProcedure); } public async Task SyncManuscriptAsync(int bookId, int userId, WordCompanionSyncManuscriptRequest 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, scene.ContentHash }) }); using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.WordCompanion_Manuscript_Sync", new { BookID = bookId, UserID = userId, request.DocumentId, StructureJson = System.Text.Json.JsonSerializer.Serialize(chapters) }, commandType: CommandType.StoredProcedure); } public async Task UpdateSceneLinksAsync(int sceneId, int userId, WordCompanionUpdateSceneLinksRequest request) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.WordCompanion_SceneLinks_Update", new { SceneID = sceneId, UserID = userId, CharacterIds = ToCsv(request.CharacterIds), AssetIds = ToCsv(request.AssetIds), LocationIds = ToCsv(request.LocationIds), request.PrimaryLocationId }, commandType: CommandType.StoredProcedure); } public async Task UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.WordCompanion_Scene_UpdateWordCount", new { SceneID = sceneId, UserID = userId, ActualWords = actualWords, LastWorkedOn = lastWorkedOn }, commandType: CommandType.StoredProcedure); } public async Task UpdateRuntimeSceneWordCountAsync(int userId, WordCompanionRuntimeSceneWordCountRequest request) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.WordCompanion_Runtime_SceneWordCount_Update", new { UserID = userId, request.DocumentGuid, request.BookId, request.ChapterId, request.SceneId, request.WordCount }, commandType: CommandType.StoredProcedure); } public async Task ValidateRuntimeCurrentSceneAsync(int userId, WordCompanionRuntimeCurrentSceneRequest request) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.WordCompanion_Runtime_CurrentScene_Validate", new { UserID = userId, request.DocumentGuid, request.ProjectId, request.BookId, request.ChapterId, request.SceneId }, commandType: CommandType.StoredProcedure); } public async Task> ListRuntimeCharacterAliasesAsync(int userId, int projectId, Guid documentGuid) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.WordCompanion_Runtime_CharacterAlias_List", new { UserID = userId, ProjectID = projectId, DocumentGuid = documentGuid }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task CreateRuntimeCharacterSuggestionsAsync(int userId, WordCompanionRuntimeCharacterSuggestionsRequest request) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.WordCompanion_Runtime_CharacterSuggestions_Create", new { UserID = userId, request.DocumentGuid, request.SceneId, CharacterIds = ToCsv(request.CharacterIds) }, commandType: CommandType.StoredProcedure); } public async Task> ListRuntimeAssetAliasesAsync(int userId, int projectId, Guid documentGuid) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.WordCompanion_Runtime_AssetAlias_List", new { UserID = userId, ProjectID = projectId, DocumentGuid = documentGuid }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task CreateRuntimeAssetSuggestionsAsync(int userId, WordCompanionRuntimeAssetSuggestionsRequest request) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.WordCompanion_Runtime_AssetSuggestions_Create", new { UserID = userId, request.DocumentGuid, request.SceneId, AssetIds = ToCsv(request.AssetIds) }, commandType: CommandType.StoredProcedure); } public async Task> ListRuntimeLocationAliasesAsync(int userId, int projectId, Guid documentGuid) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.WordCompanion_Runtime_LocationAlias_List", new { UserID = userId, ProjectID = projectId, DocumentGuid = documentGuid }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task CreateRuntimeLocationSuggestionsAsync(int userId, WordCompanionRuntimeLocationSuggestionsRequest request) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.WordCompanion_Runtime_LocationSuggestions_Create", new { UserID = userId, request.DocumentGuid, request.SceneId, LocationIds = ToCsv(request.LocationIds) }, 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 }; } public async Task HasProjectAccessAsync(int projectId, int userId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.WordCompanion_Project_HasAccess", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); } public async Task CreateDiscoveredCharactersAsync(int userId, WordCompanionCreateDiscoveredCharactersRequest request) { var names = request.CandidateNames .Select(name => new { Name = name }) .ToList(); using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync( "dbo.WordCompanion_Character_CreateDiscovered", new { UserID = userId, request.ProjectId, CandidatesJson = System.Text.Json.JsonSerializer.Serialize(names) }, commandType: CommandType.StoredProcedure); var header = await result.ReadSingleOrDefaultAsync(); if (header is null) { return null; } var characters = (await result.ReadAsync()).ToList(); return new WordCompanionCreateDiscoveredCharactersResponse { ProjectId = header.ProjectId, CreatedCount = header.CreatedCount, SkippedCount = header.SkippedCount, Message = header.Message, Characters = characters }; } private static string ToCsv(IEnumerable ids) => string.Join(',', ids.Where(id => id > 0).Distinct()); private sealed class WordCompanionSceneStructureRow { public int ChapterId { get; set; } public int SceneId { get; set; } public string Title { get; set; } = string.Empty; public int SortOrder { get; set; } public string RevisionStatus { get; set; } = string.Empty; public int? EstimatedWords { get; set; } public int? ActualWords { get; set; } } }