using PlotLine.Data; using PlotLine.Models; namespace PlotLine.Services; public interface IManuscriptTextStatisticsService { ManuscriptTextStatistics Calculate(string? sourceText); ManuscriptTextStatistics Aggregate(IEnumerable statistics); Task RecalculateSceneAsync(int sceneId); Task RecalculateChapterAsync(int chapterId); Task RecalculateBookAsync(int bookId); } public sealed class ManuscriptTextStatisticsService( IManuscriptTextStatisticsRepository repository, IOnboardingBuildRepository manuscriptSources) : IManuscriptTextStatisticsService { public ManuscriptTextStatistics Calculate(string? sourceText) { var paragraphs = StoryIntelligenceParagraphs.Split(sourceText); var totalWords = 0; var dialogueWords = 0; var proseWords = 0; var dialogueParagraphs = 0; var proseParagraphs = 0; var inDialogue = false; foreach (var paragraph in paragraphs) { var paragraphStartInDialogue = inDialogue; var paragraphHasDialogue = paragraphStartInDialogue; var paragraphHasProse = false; var firstContentIndex = FirstNonWhitespaceIndex(paragraph); var segmentStart = 0; for (var index = 0; index < paragraph.Length; index++) { var quote = QuoteKind(paragraph[index]); if (quote == ManuscriptQuoteKind.None) { continue; } if (inDialogue && IsContinuationOpeningQuote(quote, index, firstContentIndex)) { AddSegment(paragraph, segmentStart, index, inDialogue, ref totalWords, ref dialogueWords, ref proseWords, ref paragraphHasDialogue, ref paragraphHasProse); segmentStart = index + 1; continue; } AddSegment(paragraph, segmentStart, index, inDialogue, ref totalWords, ref dialogueWords, ref proseWords, ref paragraphHasDialogue, ref paragraphHasProse); if (quote == ManuscriptQuoteKind.SmartOpen) { inDialogue = true; paragraphHasDialogue = true; } else if (quote == ManuscriptQuoteKind.SmartClose) { inDialogue = false; } else { inDialogue = !inDialogue; if (inDialogue) { paragraphHasDialogue = true; } } segmentStart = index + 1; } AddSegment(paragraph, segmentStart, paragraph.Length, inDialogue, ref totalWords, ref dialogueWords, ref proseWords, ref paragraphHasDialogue, ref paragraphHasProse); if (paragraphHasDialogue) { dialogueParagraphs++; } if (paragraphHasProse) { proseParagraphs++; } } return new ManuscriptTextStatistics { TotalWordCount = totalWords, DialogueWordCount = dialogueWords, ProseWordCount = proseWords, TotalParagraphCount = paragraphs.Count, DialogueParagraphCount = dialogueParagraphs, ProseParagraphCount = proseParagraphs }; } public ManuscriptTextStatistics Aggregate(IEnumerable statistics) { var rows = statistics.Where(row => row is not null).Select(row => row!).ToList(); return new ManuscriptTextStatistics { TotalWordCount = rows.Sum(row => row.TotalWordCount), DialogueWordCount = rows.Sum(row => row.DialogueWordCount), ProseWordCount = rows.Sum(row => row.ProseWordCount), TotalParagraphCount = rows.Sum(row => row.TotalParagraphCount), DialogueParagraphCount = rows.Sum(row => row.DialogueParagraphCount), ProseParagraphCount = rows.Sum(row => row.ProseParagraphCount) }; } public async Task RecalculateSceneAsync(int sceneId) { var source = await repository.GetSourceBySceneAsync(sceneId); if (source is null) { return; } await repository.SaveAsync(source.SceneID, Calculate(source.SourceText)); } public async Task RecalculateChapterAsync(int chapterId) { var sources = await manuscriptSources.ListSceneManuscriptSourcesByChapterAsync(chapterId); foreach (var source in sources) { await repository.SaveAsync(source.SceneID, Calculate(source.SourceText)); } } public async Task RecalculateBookAsync(int bookId) { var sources = await manuscriptSources.ListSceneManuscriptSourcesByBookAsync(bookId); foreach (var source in sources) { await repository.SaveAsync(source.SceneID, Calculate(source.SourceText)); } } private static void AddSegment( string paragraph, int start, int end, bool isDialogue, ref int totalWords, ref int dialogueWords, ref int proseWords, ref bool paragraphHasDialogue, ref bool paragraphHasProse) { if (end <= start) { return; } var words = CountWords(paragraph[start..end]); if (words == 0) { return; } totalWords += words; if (isDialogue) { dialogueWords += words; paragraphHasDialogue = true; } else { proseWords += words; paragraphHasProse = true; } } private static int CountWords(string value) => string.IsNullOrWhiteSpace(value) ? 0 : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; private static int FirstNonWhitespaceIndex(string paragraph) { for (var index = 0; index < paragraph.Length; index++) { if (!char.IsWhiteSpace(paragraph[index])) { return index; } } return -1; } private static bool IsContinuationOpeningQuote(ManuscriptQuoteKind quote, int index, int firstContentIndex) => index == firstContentIndex && (quote == ManuscriptQuoteKind.SmartOpen || quote == ManuscriptQuoteKind.Straight); private static ManuscriptQuoteKind QuoteKind(char value) => value switch { '"' => ManuscriptQuoteKind.Straight, '\u201c' => ManuscriptQuoteKind.SmartOpen, '\u201d' => ManuscriptQuoteKind.SmartClose, _ => ManuscriptQuoteKind.None }; private enum ManuscriptQuoteKind { None, Straight, SmartOpen, SmartClose } }