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 GetRuntimeBookAsync(int bookId, Guid documentGuid); 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( @"(? 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", "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", "What", "Not", "Just", "Yeah", "How", "Well", "Are", "Like", "One", "Can", "Thank", "Why", "Come", "Let", "Maybe", "Please", "God", "Something", "After", "Did", "Even", "All", "Nothing", "Have", "Don", "Won", "Could", "Would", "Should", "Do", "Does", "Had", "Has", "Was", "Were", "Is", "Am", "Right", "Really", "Now", "Look", "Listen", "Sorry", "Thanks", "Fine", "Great", "Sure", "Course", "Thing", "Things", "Everyone", "Everything", "Anything", "Someone", "Nobody", "People", "Still", "Err", "Er", "Erm", "Alright", "Allright", "Alrite", "New", "Somewhere", "Coke" }; private static readonly HashSet CharacterDiscoveryTemporalWords = new(StringComparer.OrdinalIgnoreCase) { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", "Spring", "Summer", "Autumn", "Fall", "Winter" }; private static readonly HashSet CharacterDiscoveryNameLikeWords = new(StringComparer.OrdinalIgnoreCase) { "Grace", "Jack", "Rose", "May", "Will", "Mark", "Hope", "Faith", "Pat", "Rob", "Bill", "Harry", "November", "Autumn", "Rain" }; private static readonly HashSet CharacterDiscoveryHonorifics = new(StringComparer.OrdinalIgnoreCase) { "Mr", "Mrs", "Ms", "Miss", "Dr", "Rev", "Fr", "Sir", "Lady", "Lord", "DS", "DI", "DC", "PC", "Professor", "Aunt", "Uncle" }; private static readonly HashSet CharacterDiscoveryDialogueTags = new(StringComparer.OrdinalIgnoreCase) { "said", "asked", "replied", "answered", "whispered", "shouted", "called", "muttered", "cried", "snapped", "sighed", "laughed", "continued", "began", "told", "yelled" }; private static readonly HashSet CharacterDiscoveryLocationIndicators = new(StringComparer.OrdinalIgnoreCase) { "Road", "Lane", "Street", "Close", "Avenue", "Drive", "Way", "Hill", "Hospital", "Inn", "Church", "School", "House", "Farm", "Hall", "Court", "Station", "Hotel", "Shop", "Cafe", "Café", "Pub", "Park", "Gardens" }; 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 GetRuntimeBookAsync(int bookId, Guid documentGuid) { if (bookId <= 0 || documentGuid == Guid.Empty) { return null; } var userId = RequireUserId(); var document = await manuscriptDocuments.GetByGuidAsync(documentGuid, userId); if (document is null || document.BookID != bookId || !document.IsActive) { return null; } await manuscriptDocuments.UpdateLastOpenedAsync(document.ManuscriptDocumentID, userId); document = await manuscriptDocuments.GetByGuidAsync(documentGuid, userId) ?? document; var projects = await repository.ListProjectsAsync(userId); var project = projects.FirstOrDefault(item => item.ProjectId == document.ProjectID); if (project is null) { return null; } var books = await repository.ListBooksAsync(document.ProjectID, userId); var book = books.FirstOrDefault(item => item.BookId == document.BookID); if (book is null) { return null; } return new WordCompanionRuntimeBookResponse { ProjectId = project.ProjectId, ProjectTitle = project.Title, BookId = book.BookId, BookTitle = book.Title, BookSubtitle = book.Subtitle, UsesExplicitScenes = book.UsesExplicitScenes, LastSyncUtc = document.LastSyncUtc, ManuscriptDocument = ToDto(document) }; } 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 candidates = new Dictionary(StringComparer.Ordinal); foreach (Match match in CharacterCandidateRegex.Matches(manuscriptText)) { var candidate = NormalizeCandidateName(match.Value); if (!IsLikelyCharacterCandidate(candidate)) { continue; } if (!candidates.TryGetValue(candidate, out var evidence)) { evidence = new CharacterCandidateEvidence(candidate); candidates[candidate] = evidence; } evidence.MentionCount += 1; if (AppearsAtSentenceStart(manuscriptText, match.Index)) { evidence.SentenceStartMentions += 1; } if (HasDialogueTagEvidence(manuscriptText, match.Index, match.Length)) { evidence.DialogueEvidenceCount += 1; } } return candidates.Values .Where(item => item.MentionCount >= CharacterDiscoveryMinimumMentions) .Select(BuildCandidateDto) .Where(item => item is not null) .Select(item => item!) .OrderByDescending(item => ConfidenceSortValue(item.Confidence)) .ThenByDescending(item => item.MentionCount) .ThenBy(item => item.Text, StringComparer.OrdinalIgnoreCase) .Take(CharacterDiscoverySuggestionLimit) .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 WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence) { var score = 0; var words = evidence.Text.Split(' ', StringSplitOptions.RemoveEmptyEntries); var first = words.Length > 0 ? TrimHonorificPunctuation(words[0]) : string.Empty; var hasHonorific = words.Length > 1 && CharacterDiscoveryHonorifics.Contains(first); var hasMultiWordName = words.Length > 1; var hasNameLikeWord = words.Any(word => CharacterDiscoveryNameLikeWords.Contains(TrimHonorificPunctuation(word))); var hasTemporalWord = words.Any(word => CharacterDiscoveryTemporalWords.Contains(TrimHonorificPunctuation(word))); var hasLocationIndicator = words.Any(word => CharacterDiscoveryLocationIndicators.Contains(TrimHonorificPunctuation(word))); var nonSentenceStartMentions = evidence.MentionCount - evidence.SentenceStartMentions; var mostlySentenceStart = evidence.MentionCount > 0 && evidence.SentenceStartMentions >= Math.Ceiling(evidence.MentionCount * 0.8m); if (evidence.MentionCount >= CharacterDiscoveryMinimumMentions) { score += 1; } if (evidence.MentionCount >= 10) { score += 1; } if (nonSentenceStartMentions > 0) { score += 2; } if (evidence.DialogueEvidenceCount > 0) { score += 5; } if (hasMultiWordName) { score += 5; } if (hasHonorific) { score += 4; } if (hasNameLikeWord) { score += 3; } if (hasTemporalWord) { score -= 1; } if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific) { score -= 2; } if (mostlySentenceStart) { score -= 2; } if (!hasMultiWordName && evidence.DialogueEvidenceCount == 0 && nonSentenceStartMentions == 0) { score -= 1; } if (score < 0 && !hasLocationIndicator) { return null; } var confidence = score >= 4 ? "High" : score >= 2 ? "Medium" : "Low"; if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific) { confidence = score >= 4 ? "Medium" : "Low"; } return new WordCompanionCharacterCandidateDto { Text = evidence.Text, MentionCount = evidence.MentionCount, Confidence = confidence, Reason = BuildCandidateReason(evidence, hasHonorific, hasMultiWordName, hasTemporalWord, hasLocationIndicator, mostlySentenceStart) }; } private static string BuildCandidateReason( CharacterCandidateEvidence evidence, bool hasHonorific, bool hasMultiWordName, bool hasTemporalWord, bool hasLocationIndicator, bool mostlySentenceStart) { if (evidence.DialogueEvidenceCount > 0) { return "Dialogue evidence"; } if (hasHonorific) { return "Title or honorific"; } if (hasMultiWordName) { if (hasLocationIndicator) { return "Possible location"; } return "Proper-name phrase"; } if (hasTemporalWord) { return "Possible name or temporal word"; } if (mostlySentenceStart) { return "Mostly sentence starts"; } return evidence.MentionCount >= 10 ? "Frequent possible name" : "Possible name"; } private static int ConfidenceSortValue(string confidence) { return string.Equals(confidence, "High", StringComparison.OrdinalIgnoreCase) ? 3 : string.Equals(confidence, "Medium", StringComparison.OrdinalIgnoreCase) ? 2 : 1; } private static bool AppearsAtSentenceStart(string manuscriptText, int matchIndex) { for (var i = matchIndex - 1; i >= 0; i--) { var character = manuscriptText[i]; if (char.IsWhiteSpace(character)) { if (character is '\n' or '\r') { return true; } continue; } if (character is '"' or '\'' or '’' or '“' or '”' or '(' or '[') { continue; } return character is '.' or '!' or '?' or ':' or ';'; } return true; } private static bool HasDialogueTagEvidence(string manuscriptText, int matchIndex, int matchLength) { var before = manuscriptText[Math.Max(0, matchIndex - 48)..matchIndex]; var afterStart = matchIndex + matchLength; var after = manuscriptText[afterStart..Math.Min(manuscriptText.Length, afterStart + 48)]; return HasTagAfterCandidate(after) || HasTagBeforeCandidate(before); } private static bool HasTagAfterCandidate(string textAfterCandidate) { var match = Regex.Match(textAfterCandidate, @"^\s*,?\s*(?[a-z]+)\b", RegexOptions.IgnoreCase); return match.Success && CharacterDiscoveryDialogueTags.Contains(match.Groups["tag"].Value); } private static bool HasTagBeforeCandidate(string textBeforeCandidate) { var match = Regex.Match(textBeforeCandidate, @"\b(?[a-z]+)\s*,?\s*[""'\u2019\u201d]?\s*$", RegexOptions.IgnoreCase); return match.Success && CharacterDiscoveryDialogueTags.Contains(match.Groups["tag"].Value); } 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 sealed class CharacterCandidateEvidence(string text) { public string Text { get; } = text; public int MentionCount { get; set; } public int SentenceStartMentions { get; set; } public int DialogueEvidenceCount { get; set; } } 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 }; }