using System.Text.RegularExpressions; using PlotLine.Data; using PlotLine.Models; namespace PlotLine.Services; 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); 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); Task UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request); Task UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request); Task GetManuscriptForBookAsync(int bookId); Task LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request); Task AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request); Task ImportManuscriptAsync(WordCompanionManuscriptImportRequest request); Task DiscoverCharacterCandidatesAsync(WordCompanionDiscoverCharactersRequest request); Task CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request); } public sealed class WordCompanionService( IWordCompanionRepository repository, IManuscriptDocumentRepository manuscriptDocuments, ICurrentUserService currentUser) : IWordCompanionService { private const int CharacterDiscoveryMinimumMentions = 5; private const int CharacterDiscoverySuggestionLimit = 50; private static readonly Regex CharacterCandidateRegex = new( @"\b(?:(?:Mr|Mrs|Ms|Miss|Dr|Rev|Fr|Sir|Lady|Lord|DS|DI|DC|PC)\.?\s+)?[A-Z][a-z]+(?:[-'][A-Z]?[a-z]+)?(?:\s+[A-Z][a-z]+(?:[-'][A-Z]?[a-z]+)?){0,2}\b", RegexOptions.Compiled); private static readonly HashSet CharacterDiscoveryStopWords = new(StringComparer.OrdinalIgnoreCase) { "A", "An", "And", "As", "At", "But", "By", "For", "From", "He", "Her", "His", "I", "If", "In", "Into", "It", "Its", "Of", "On", "Or", "Our", "She", "So", "That", "The", "Their", "Then", "There", "They", "This", "To", "We", "When", "Where", "Who", "With", "You", "Your", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", "Christmas", "Easter", "Morning", "Afternoon", "Evening", "Night", "Today", "Tomorrow", "Yesterday", "Chapter", "Scene", "Part", "Prologue", "Epilogue", "Book", "Volume", "Act", "Mum", "Dad", "Mother", "Father", "Grandma", "Grandad", "Nan", "Nana", "Granny", "Yes", "No", "Okay", "Ok", "Hello", "Goodbye" }; public async Task ListProjectsAsync() { var userId = RequireUserId(); return new WordCompanionProjectsResponse { Projects = await repository.ListProjectsAsync(userId) }; } public async Task ListBooksAsync(int projectId) { if (projectId <= 0) { return null; } var books = await repository.ListBooksAsync(projectId, RequireUserId()); 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()); } 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)) { return Task.FromResult(null); } return ResolveSceneCoreAsync(bookId, request); } public Task GetSceneCompanionAsync(int sceneId) { 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)) { return Task.FromResult(null); } request.SceneTitle = request.SceneTitle.Trim(); return repository.CreateSceneAsync(bookId, chapterId, RequireUserId(), request); } public Task SyncSceneAsync(int bookId, WordCompanionSyncSceneRequest request) { if (bookId <= 0 || string.IsNullOrWhiteSpace(request.ChapterTitle) || string.IsNullOrWhiteSpace(request.SceneTitle) || request.ActualWords < 0 || request.ChapterSortOrder is <= 0 || request.SceneSortOrder is <= 0) { return Task.FromResult(null); } request.ChapterTitle = request.ChapterTitle.Trim(); request.SceneTitle = request.SceneTitle.Trim(); return repository.SyncSceneAsync(bookId, RequireUserId(), request); } public Task SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request) { if (bookId <= 0 || request.Chapters.Any(chapter => string.IsNullOrWhiteSpace(chapter.Title) || chapter.WordCount < 0 || chapter.SortOrder is <= 0 || chapter.Scenes.Any(scene => string.IsNullOrWhiteSpace(scene.Title) || scene.WordCount < 0 || scene.SortOrder is <= 0))) { return Task.FromResult(null); } foreach (var chapter in request.Chapters) { chapter.Title = chapter.Title?.Trim(); foreach (var scene in chapter.Scenes) { scene.Title = scene.Title?.Trim(); } } return repository.SyncManuscriptAsync(bookId, RequireUserId(), request); } public Task UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request) { if (sceneId <= 0 || request.PrimaryLocationId is <= 0 || HasInvalidIds(request.CharacterIds) || HasInvalidIds(request.AssetIds) || HasInvalidIds(request.LocationIds)) { return Task.FromResult(null); } return repository.UpdateSceneLinksAsync(sceneId, RequireUserId(), request); } public Task UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request) { var actualWords = request.ActualWordCount ?? request.ActualWords; return sceneId <= 0 || actualWords is null or < 0 ? Task.FromResult(null) : repository.UpdateWordCountAsync(sceneId, RequireUserId(), actualWords.Value, request.LastWorkedOn); } public async Task GetManuscriptForBookAsync(int bookId) { if (bookId <= 0) { return null; } var document = await manuscriptDocuments.GetByBookAsync(bookId, RequireUserId()); return new WordCompanionManuscriptBookResponse { BookId = bookId, ManuscriptDocument = document is null ? null : ToDto(document) }; } public async Task LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request) { if (request.BookId <= 0) { return null; } var documentGuid = request.DocumentGuid.GetValueOrDefault(); if (documentGuid == Guid.Empty) { documentGuid = Guid.NewGuid(); } ManuscriptDocumentModel? document; try { document = await manuscriptDocuments.SaveAsync(request.BookId, documentGuid, RequireUserId()); } catch (InvalidOperationException) { return null; } return document is null ? null : new WordCompanionManuscriptLinkResponse { BookId = document.BookID, ManuscriptDocument = ToDto(document), Message = "Manuscript linked." }; } 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); } public async Task DiscoverCharacterCandidatesAsync(WordCompanionDiscoverCharactersRequest request) { if (request.ProjectId <= 0) { return null; } var userId = RequireUserId(); if (!await repository.HasProjectAccessAsync(request.ProjectId, userId)) { return null; } var candidates = DiscoverCharacterCandidates(request.DocumentText ?? string.Empty); return new WordCompanionDiscoverCharactersResponse { ProjectId = request.ProjectId, Candidates = candidates }; } public Task CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request) { if (request.ProjectId <= 0 || request.CandidateNames.Count == 0) { return Task.FromResult(null); } request.CandidateNames = request.CandidateNames .Select(NormalizeCandidateName) .Where(name => !string.IsNullOrWhiteSpace(name) && name.Length <= 200) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(CharacterDiscoverySuggestionLimit) .ToList(); return request.CandidateNames.Count == 0 ? Task.FromResult(null) : repository.CreateDiscoveredCharactersAsync(RequireUserId(), request); } private async Task ResolveSceneCoreAsync(int bookId, WordCompanionResolveSceneRequest request) { return await repository.ResolveSceneAsync(bookId, RequireUserId(), request.ChapterTitle!.Trim(), request.SceneTitle!.Trim()); } private int RequireUserId() { return currentUser.UserId ?? throw new InvalidOperationException("An authenticated user is required."); } private static bool HasInvalidIds(IEnumerable ids) => ids.Any(id => id <= 0); private static IReadOnlyList DiscoverCharacterCandidates(string manuscriptText) { if (string.IsNullOrWhiteSpace(manuscriptText)) { return []; } var mentions = new Dictionary(StringComparer.Ordinal); foreach (Match match in CharacterCandidateRegex.Matches(manuscriptText)) { var candidate = NormalizeCandidateName(match.Value); if (!IsLikelyCharacterCandidate(candidate)) { continue; } mentions[candidate] = mentions.TryGetValue(candidate, out var count) ? count + 1 : 1; } return mentions .Where(item => item.Value >= CharacterDiscoveryMinimumMentions) .OrderByDescending(item => item.Value) .ThenBy(item => item.Key, StringComparer.OrdinalIgnoreCase) .Take(CharacterDiscoverySuggestionLimit) .Select(item => new WordCompanionCharacterCandidateDto { Text = item.Key, MentionCount = item.Value }) .ToList(); } private static bool IsLikelyCharacterCandidate(string candidate) { if (string.IsNullOrWhiteSpace(candidate) || candidate.Length > 200) { return false; } var words = candidate.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (words.Length == 0 || words.Any(word => CharacterDiscoveryStopWords.Contains(TrimHonorificPunctuation(word)))) { return false; } var first = TrimHonorificPunctuation(words[0]); return words.Length > 1 || first.Length > 2; } private static string NormalizeCandidateName(string candidate) { return Regex.Replace(candidate ?? string.Empty, @"\s+", " ").Trim().Trim(',', '.', ';', ':', '!', '?', '"', '\''); } private static string TrimHonorificPunctuation(string word) { return (word ?? string.Empty).Trim().TrimEnd('.'); } private static WordCompanionManuscriptDocumentDto ToDto(ManuscriptDocumentModel document) => new() { ManuscriptDocumentId = document.ManuscriptDocumentID, BookId = document.BookID, ProjectId = document.ProjectID, DocumentGuid = document.DocumentGuid, BindingVersion = document.BindingVersion, CreatedUtc = document.CreatedUtc, LastSyncUtc = document.LastSyncUtc, LastOpenedUtc = document.LastOpenedUtc, IsActive = document.IsActive }; }