feat: implement epub service, navigation service, and global error boundary with updated reader UI layouts

This commit is contained in:
2026-04-25 16:16:36 +02:00
parent f3e94c4f42
commit 59074a05a0
23 changed files with 726 additions and 157 deletions
@@ -9,6 +9,7 @@ public static class DependencyInjection
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
services.AddTransient<IEpubService, EpubService>();
return services;
}
}
@@ -4,6 +4,10 @@
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="VersOne.Epub" Version="3.3.6" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
@@ -0,0 +1,149 @@
using System.Text;
using System.Text.RegularExpressions;
using FluentResults;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.Reader;
using VersOne.Epub;
namespace NexusReader.Infrastructure.Services;
public class EpubService : IEpubService
{
private const string EpubPath = "wwwroot/assets/book.epub";
private const int WordThreshold = 1000;
public async Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex)
{
try
{
// Path handling: Recursive search upwards to find the asset in development or production
var relativePath = Path.Combine("wwwroot", "assets", "book.epub");
string? fullPath = null;
var searchPaths = new List<string>();
var currentDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
while (currentDir != null)
{
var checkPath1 = Path.Combine(currentDir.FullName, relativePath);
var checkPath2 = Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New", relativePath);
searchPaths.Add(checkPath1);
if (File.Exists(checkPath1)) { fullPath = checkPath1; break; }
searchPaths.Add(checkPath2);
if (File.Exists(checkPath2)) { fullPath = checkPath2; break; }
currentDir = currentDir.Parent;
}
if (fullPath == null)
{
return Result.Fail($"EPUB file not found. Checked {searchPaths.Count} locations, including: {string.Join(", ", searchPaths.Take(3))}");
}
EpubBook book = await EpubReader.ReadBookAsync(fullPath);
var blocks = new List<ContentBlock>();
int totalWordCount = 0;
int blockCounter = 0;
if (book.ReadingOrder == null || !book.ReadingOrder.Any())
{
return Result.Fail("The EPUB has no readable content files in ReadingOrder.");
}
// Ensure index is within bounds
if (chapterIndex < 0 || chapterIndex >= book.ReadingOrder.Count)
{
chapterIndex = 0; // Default to first chapter
}
var chapter = book.ReadingOrder[chapterIndex];
var chapterTitle = chapter.FilePath ?? $"Chapter {chapterIndex + 1}";
var paragraphs = ExtractParagraphs(chapter.Content);
foreach (var p in paragraphs)
{
var sanitizedContent = SanitizeParagraph(p);
if (string.IsNullOrWhiteSpace(sanitizedContent)) continue;
// Requirement: Each paragraph mapped to its own TextSegmentBlock
blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent));
int wordsInP = CountWords(sanitizedContent);
totalWordCount += wordsInP;
// Requirement: Smart Injection after 1000 words
if (totalWordCount >= WordThreshold)
{
blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}"));
totalWordCount = 0;
}
}
// End of chapter section trigger
if (blocks.Any() && blocks.Last() is not AiActionTriggerBlock)
{
blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}"));
}
return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, book.ReadingOrder.Count, chapterTitle));
}
catch (Exception ex)
{
return Result.Fail(new Error($"Failed to process EPUB: {ex.Message}").CausedBy(ex));
}
}
private List<string> ExtractParagraphs(string html)
{
var paragraphs = new List<string>();
// Match <p> tags and their content
var matches = Regex.Matches(html, @"<p\b[^>]*>(.*?)</p>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
foreach (Match match in matches)
{
paragraphs.Add(match.Groups[1].Value);
}
// Fallback: split by double newlines if no <p> tags found
if (paragraphs.Count == 0)
{
var bodyMatch = Regex.Match(html, @"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
paragraphs = content.Split(new[] { "<br />", "<br>", "\n\n" }, StringSplitOptions.RemoveEmptyEntries).ToList();
}
return paragraphs;
}
private string SanitizeParagraph(string html)
{
// 1. Remove <style> and <script> blocks
var clean = Regex.Replace(html, @"<(style|script)\b[^>]*>.*?</\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
// 2. Remove all tags except <b>, <i>, <strong>, <em>
clean = Regex.Replace(clean, @"<(?!/?(b|i|strong|em)\b)[^>]+>", "", RegexOptions.IgnoreCase);
// 3. Requirement: Aggressively strip attributes (class, style, id) from allowed tags
clean = Regex.Replace(clean, @"<(b|i|strong|em)\b[^>]*>", "<$1>", RegexOptions.IgnoreCase);
// 4. Decode HTML entities
clean = System.Net.WebUtility.HtmlDecode(clean);
return clean.Trim();
}
private int CountWords(string text)
{
if (string.IsNullOrWhiteSpace(text)) return 0;
return text.Split(new[] { ' ', '\r', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries).Length;
}
private AiActionTriggerBlock CreateAiTrigger(string id)
{
return new AiActionTriggerBlock(
id,
"Wykryto ciekawy fragment! Czy chcesz, abym wygenerował podsumowanie lub quiz z tego rozdziału?",
new List<string> { "Podsumuj", "Generuj Quiz", "Pomiń" }
);
}
}