PlotDirector/PlotLine/Services/WordCompanionService.cs

407 lines
17 KiB
C#

using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IWordCompanionService
{
Task<WordCompanionProjectsResponse> ListProjectsAsync();
Task<WordCompanionBooksResponse?> ListBooksAsync(int projectId);
Task<WordCompanionCreateBookResponse?> CreateBookAsync(int projectId, WordCompanionCreateBookRequest request);
Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId);
Task<WordCompanionChaptersResponse?> ListChaptersAsync(int bookId);
Task<WordCompanionResolveSceneResponse?> ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request);
Task<WordCompanionSceneCompanionResponse?> GetSceneCompanionAsync(int sceneId);
Task<WordCompanionCreateChapterResponse?> CreateChapterAsync(int bookId, WordCompanionCreateChapterRequest request);
Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request);
Task<WordCompanionSyncSceneResponse?> SyncSceneAsync(int bookId, WordCompanionSyncSceneRequest request);
Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request);
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request);
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request);
Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request);
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(WordCompanionManuscriptImportRequest request);
Task<WordCompanionDiscoverCharactersResponse?> DiscoverCharacterCandidatesAsync(WordCompanionDiscoverCharactersRequest request);
Task<WordCompanionCreateDiscoveredCharactersResponse?> 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<string> 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<WordCompanionProjectsResponse> ListProjectsAsync()
{
var userId = RequireUserId();
return new WordCompanionProjectsResponse { Projects = await repository.ListProjectsAsync(userId) };
}
public async Task<WordCompanionBooksResponse?> ListBooksAsync(int projectId)
{
if (projectId <= 0)
{
return null;
}
var books = await repository.ListBooksAsync(projectId, RequireUserId());
return new WordCompanionBooksResponse { Books = books };
}
public Task<WordCompanionCreateBookResponse?> CreateBookAsync(int projectId, WordCompanionCreateBookRequest request)
{
if (projectId <= 0 || string.IsNullOrWhiteSpace(request.Title))
{
return Task.FromResult<WordCompanionCreateBookResponse?>(null);
}
request.Title = request.Title.Trim();
return repository.CreateBookAsync(projectId, RequireUserId(), request);
}
public Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId)
{
return bookId <= 0 ? Task.FromResult<WordCompanionBookStructureResponse?>(null) : repository.GetBookStructureAsync(bookId, RequireUserId());
}
public async Task<WordCompanionChaptersResponse?> ListChaptersAsync(int bookId)
{
if (bookId <= 0)
{
return null;
}
var chapters = await repository.ListChaptersAsync(bookId, RequireUserId());
return new WordCompanionChaptersResponse { Chapters = chapters };
}
public Task<WordCompanionResolveSceneResponse?> ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request)
{
if (bookId <= 0 || string.IsNullOrWhiteSpace(request.ChapterTitle) || string.IsNullOrWhiteSpace(request.SceneTitle))
{
return Task.FromResult<WordCompanionResolveSceneResponse?>(null);
}
return ResolveSceneCoreAsync(bookId, request);
}
public Task<WordCompanionSceneCompanionResponse?> GetSceneCompanionAsync(int sceneId)
{
return sceneId <= 0 ? Task.FromResult<WordCompanionSceneCompanionResponse?>(null) : repository.GetSceneCompanionAsync(sceneId, RequireUserId());
}
public Task<WordCompanionCreateChapterResponse?> CreateChapterAsync(int bookId, WordCompanionCreateChapterRequest request)
{
if (bookId <= 0 || string.IsNullOrWhiteSpace(request.Title) || request.SortOrder is <= 0)
{
return Task.FromResult<WordCompanionCreateChapterResponse?>(null);
}
request.Title = request.Title.Trim();
return repository.CreateChapterAsync(bookId, RequireUserId(), request);
}
public Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request)
{
if (bookId <= 0 || chapterId <= 0 || string.IsNullOrWhiteSpace(request.SceneTitle))
{
return Task.FromResult<WordCompanionCreateSceneResponse?>(null);
}
request.SceneTitle = request.SceneTitle.Trim();
return repository.CreateSceneAsync(bookId, chapterId, RequireUserId(), request);
}
public Task<WordCompanionSyncSceneResponse?> 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<WordCompanionSyncSceneResponse?>(null);
}
request.ChapterTitle = request.ChapterTitle.Trim();
request.SceneTitle = request.SceneTitle.Trim();
return repository.SyncSceneAsync(bookId, RequireUserId(), request);
}
public Task<WordCompanionSyncManuscriptResponse?> 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<WordCompanionSyncManuscriptResponse?>(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<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request)
{
if (sceneId <= 0
|| request.PrimaryLocationId is <= 0
|| HasInvalidIds(request.CharacterIds)
|| HasInvalidIds(request.AssetIds)
|| HasInvalidIds(request.LocationIds))
{
return Task.FromResult<WordCompanionUpdateSceneLinksResponse?>(null);
}
return repository.UpdateSceneLinksAsync(sceneId, RequireUserId(), request);
}
public Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request)
{
var actualWords = request.ActualWordCount ?? request.ActualWords;
return sceneId <= 0 || actualWords is null or < 0
? Task.FromResult<WordCompanionUpdateWordCountResponse?>(null)
: repository.UpdateWordCountAsync(sceneId, RequireUserId(), actualWords.Value, request.LastWorkedOn);
}
public async Task<WordCompanionManuscriptBookResponse?> 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<WordCompanionManuscriptLinkResponse?> 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<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request)
{
if (request.ProjectId <= 0
|| request.BookId <= 0
|| request.ChapterCount < 0
|| request.SceneCount < 0
|| request.WordCount < 0)
{
return Task.FromResult<WordCompanionManuscriptAnalyseResponse?>(null);
}
return repository.AnalyseManuscriptAsync(RequireUserId(), request);
}
public Task<WordCompanionManuscriptImportResponse?> 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<WordCompanionManuscriptImportResponse?>(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<WordCompanionDiscoverCharactersResponse?> 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<WordCompanionCreateDiscoveredCharactersResponse?> CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request)
{
if (request.ProjectId <= 0 || request.CandidateNames.Count == 0)
{
return Task.FromResult<WordCompanionCreateDiscoveredCharactersResponse?>(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<WordCompanionCreateDiscoveredCharactersResponse?>(null)
: repository.CreateDiscoveredCharactersAsync(RequireUserId(), request);
}
private async Task<WordCompanionResolveSceneResponse?> 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<int> ids) => ids.Any(id => id <= 0);
private static IReadOnlyList<WordCompanionCharacterCandidateDto> DiscoverCharacterCandidates(string manuscriptText)
{
if (string.IsNullOrWhiteSpace(manuscriptText))
{
return [];
}
var mentions = new Dictionary<string, int>(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
};
}