refactor: address PR review comments for ingestion workflow

This commit is contained in:
2026-05-12 20:12:12 +02:00
parent 94fd7cf5c1
commit 531ad3c2d0
12 changed files with 131 additions and 31 deletions
@@ -1,7 +1,29 @@
namespace NexusReader.Application.Abstractions.Services; namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// Service for managing ebook and cover file storage.
/// </summary>
public interface IBookStorageService public interface IBookStorageService
{ {
/// <summary>
/// Saves an ebook file and returns its relative path/URL.
/// </summary>
Task<string> SaveEbookAsync(byte[] data, string fileName); Task<string> SaveEbookAsync(byte[] data, string fileName);
/// <summary>
/// Saves an ebook file using a stream and returns its relative path/URL.
/// </summary>
Task<string> SaveEbookAsync(Stream data, string fileName);
/// <summary>
/// Saves a cover image and returns its relative path/URL.
/// Returns null if no cover data is provided.
/// </summary>
Task<string?> SaveCoverAsync(byte[] data, string fileName); Task<string?> SaveCoverAsync(byte[] data, string fileName);
/// <summary>
/// Saves a cover image using a stream and returns its relative path/URL.
/// Returns null if no cover data is provided.
/// </summary>
Task<string?> SaveCoverAsync(Stream data, string fileName);
} }
@@ -2,6 +2,15 @@ using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Commands.Library; namespace NexusReader.Application.Commands.Library;
/// <summary>
/// Command to ingest a new ebook into the library.
/// </summary>
/// <param name="Title">The title of the book.</param>
/// <param name="AuthorName">The name of the author.</param>
/// <param name="CoverImage">The raw bytes of the cover image (optional).</param>
/// <param name="EpubData">The raw bytes of the EPUB file.</param>
/// <param name="UserId">The ID of the user owning the book.</param>
/// <param name="TenantId">The tenant ID for multi-tenant isolation. Defaults to "global" for single-tenant or default usage.</param>
public record IngestEbookCommand( public record IngestEbookCommand(
string Title, string Title,
string AuthorName, string AuthorName,
@@ -23,18 +23,30 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
public async Task<Result<Guid>> Handle(IngestEbookCommand request, CancellationToken cancellationToken) public async Task<Result<Guid>> Handle(IngestEbookCommand request, CancellationToken cancellationToken)
{ {
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
string epubPath;
string? coverUrl;
try try
{ {
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
// 1. Save Files // 1. Save Files
var epubPath = await _storageService.SaveEbookAsync(request.EpubData, $"{request.Title}.epub"); epubPath = await _storageService.SaveEbookAsync(request.EpubData, $"{request.Title}.epub");
var coverUrl = request.CoverImage != null && request.CoverImage.Length > 0 coverUrl = request.CoverImage != null && request.CoverImage.Length > 0
? await _storageService.SaveCoverAsync(request.CoverImage, $"{request.Title}_cover.jpg") ? await _storageService.SaveCoverAsync(request.CoverImage, $"{request.Title}_cover.jpg")
: null; : null;
}
catch (Exception ex)
{
return Result.Fail(new Error($"Storage failure: {ex.Message}").CausedBy(ex));
}
try
{
// 2. Resolve Author // 2. Resolve Author
var authorName = string.IsNullOrWhiteSpace(request.AuthorName) ? "Unknown Author" : request.AuthorName.Trim(); var authorName = string.IsNullOrWhiteSpace(request.AuthorName) ? "Unknown Author" : request.AuthorName.Trim();
// Use case-insensitive comparison
var author = await context.Authors var author = await context.Authors
.FirstOrDefaultAsync(a => a.Name.ToLower() == authorName.ToLower(), cancellationToken); .FirstOrDefaultAsync(a => a.Name.ToLower() == authorName.ToLower(), cancellationToken);
@@ -42,8 +54,6 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
{ {
author = new Author { Name = authorName }; author = new Author { Name = authorName };
context.Authors.Add(author); context.Authors.Add(author);
// We need to save to get the Author ID if we want to use it,
// but EF will handle it if we assign the object.
} }
// 3. Create Ebook // 3. Create Ebook
@@ -51,7 +61,7 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
{ {
Title = request.Title, Title = request.Title,
Author = author, Author = author,
FilePath = epubPath, FilePath = epubPath, // Relative URL from wwwroot
CoverUrl = coverUrl, CoverUrl = coverUrl,
UserId = request.UserId, UserId = request.UserId,
TenantId = request.TenantId, TenantId = request.TenantId,
@@ -63,9 +73,13 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
return Result.Ok(ebook.Id); return Result.Ok(ebook.Id);
} }
catch (DbUpdateException ex)
{
return Result.Fail(new Error($"Database error during ingestion: {ex.Message}").CausedBy(ex));
}
catch (Exception ex) catch (Exception ex)
{ {
return Result.Fail(new Error("Failed to ingest ebook").CausedBy(ex)); return Result.Fail(new Error($"Unexpected error during ingestion: {ex.Message}").CausedBy(ex));
} }
} }
} }
@@ -1,8 +1,22 @@
namespace NexusReader.Application.Queries.Reader; namespace NexusReader.Application.Queries.Reader;
/// <summary>
/// Represents metadata extracted from a local EPUB file.
/// </summary>
public record LocalEpubMetadata public record LocalEpubMetadata
{ {
/// <summary>
/// The title of the book.
/// </summary>
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
/// <summary>
/// The author(s) of the book.
/// </summary>
public string Author { get; set; } = string.Empty; public string Author { get; set; } = string.Empty;
/// <summary>
/// The raw bytes of the cover image, if available.
/// </summary>
public byte[]? CoverImage { get; set; } public byte[]? CoverImage { get; set; }
} }
@@ -75,7 +75,7 @@ public static class DependencyInjection
services.AddScoped<IKnowledgeService, KnowledgeService>(); services.AddScoped<IKnowledgeService, KnowledgeService>();
services.AddTransient<IEpubReader, EpubReaderService>(); services.AddTransient<IEpubReader, EpubReaderService>();
services.AddTransient<IEpubMetadataExtractor, EpubMetadataExtractor>(); services.AddTransient<IEpubMetadataExtractor, EpubMetadataExtractor>();
services.AddScoped<IBookStorageService, BookStorageService>(); services.AddSingleton<IBookStorageService, BookStorageService>();
services.AddAuthorizationCore(options => services.AddAuthorizationCore(options =>
{ {
@@ -3,6 +3,10 @@ using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Infrastructure.Services; namespace NexusReader.Infrastructure.Services;
/// <summary>
/// Infrastructure implementation of book storage using local filesystem.
/// All paths returned are relative to the web root.
/// </summary>
public class BookStorageService : IBookStorageService public class BookStorageService : IBookStorageService
{ {
private readonly IWebHostEnvironment _environment; private readonly IWebHostEnvironment _environment;
@@ -13,38 +17,55 @@ public class BookStorageService : IBookStorageService
} }
public async Task<string> SaveEbookAsync(byte[] data, string fileName) public async Task<string> SaveEbookAsync(byte[] data, string fileName)
{
using var stream = new MemoryStream(data);
return await SaveEbookAsync(stream, fileName);
}
public async Task<string> SaveEbookAsync(Stream data, string fileName)
{ {
var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads"); var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads");
if (!Directory.Exists(uploadsFolder)) EnsureDirectoryExists(uploadsFolder);
{
Directory.CreateDirectory(uploadsFolder);
}
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
var filePath = Path.Combine(uploadsFolder, uniqueFileName); var filePath = Path.Combine(uploadsFolder, uniqueFileName);
await File.WriteAllBytesAsync(filePath, data); using (var fileStream = new FileStream(filePath, FileMode.Create))
{
await data.CopyToAsync(fileStream);
}
// Return relative path for web access if needed, but entity expects FilePath
// Let's return the relative path from wwwroot
return Path.Combine("uploads", uniqueFileName); return Path.Combine("uploads", uniqueFileName);
} }
public async Task<string?> SaveCoverAsync(byte[] data, string fileName) public async Task<string?> SaveCoverAsync(byte[] data, string fileName)
{ {
if (data == null || data.Length == 0) return null; if (data == null || data.Length == 0) return null;
using var stream = new MemoryStream(data);
return await SaveCoverAsync(stream, fileName);
}
public async Task<string?> SaveCoverAsync(Stream data, string fileName)
{
var coversFolder = Path.Combine(_environment.WebRootPath, "covers"); var coversFolder = Path.Combine(_environment.WebRootPath, "covers");
if (!Directory.Exists(coversFolder)) EnsureDirectoryExists(coversFolder);
{
Directory.CreateDirectory(coversFolder);
}
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
var filePath = Path.Combine(coversFolder, uniqueFileName); var filePath = Path.Combine(coversFolder, uniqueFileName);
await File.WriteAllBytesAsync(filePath, data); using (var fileStream = new FileStream(filePath, FileMode.Create))
{
await data.CopyToAsync(fileStream);
}
return Path.Combine("covers", uniqueFileName); return Path.Combine("covers", uniqueFileName);
} }
private void EnsureDirectoryExists(string path)
{
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
}
} }
@@ -216,8 +216,7 @@
if (result != null) if (result != null)
{ {
await CloseModal(); await CloseModal();
// Navigate to the newly added book ReaderNavigation.NavigateToBook(result.Id);
// ReaderNavigation.NavigateToBook(result.Id);
} }
} }
else else
@@ -291,7 +291,7 @@
position: absolute; position: absolute;
width: 150%; width: 150%;
height: 150%; height: 150%;
background: radial-gradient(circle, rgba(0, 255, 153, 0.15) 0%, transparent 70%); background: radial-gradient(circle, var(--nexus-neon-alpha, rgba(0, 255, 153, 0.15)) 0%, transparent 70%);
animation: pulseGlow 4s infinite alternate; animation: pulseGlow 4s infinite alternate;
} }
@@ -299,7 +299,7 @@
color: var(--nexus-neon, #00ffaa); color: var(--nexus-neon, #00ffaa);
opacity: 0.5; opacity: 0.5;
z-index: 1; z-index: 1;
filter: drop-shadow(0 0 10px rgba(0, 255, 153, 0.3)); filter: drop-shadow(0 0 10px var(--nexus-neon-alpha-deep, rgba(0, 255, 153, 0.3)));
} }
.verification-form { .verification-form {
@@ -341,13 +341,13 @@
.error-message { .error-message {
margin-top: 1rem; margin-top: 1rem;
color: #ff5555; color: var(--nexus-error, #ff5555);
text-align: center; text-align: center;
font-size: 0.9rem; font-size: 0.9rem;
padding: 0.75rem; padding: 0.75rem;
background: rgba(255, 85, 85, 0.1); background: var(--nexus-error-alpha, rgba(255, 85, 85, 0.1));
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(255, 85, 85, 0.2); border: 1px solid var(--nexus-error-alpha-deep, rgba(255, 85, 85, 0.2));
} }
@keyframes pulseGlow { @keyframes pulseGlow {
@@ -12,4 +12,9 @@ public interface IReaderNavigationService
Task GoToNextChapter(); Task GoToNextChapter();
Task GoToPreviousChapter(); Task GoToPreviousChapter();
Task UpdateMetadataAsync(int currentIndex, int totalChapters, string title); Task UpdateMetadataAsync(int currentIndex, int totalChapters, string title);
/// <summary>
/// Navigates to the reader for a specific book.
/// </summary>
void NavigateToBook(Guid bookId);
} }
@@ -1,9 +1,17 @@
using System.Linq; using System.Linq;
using Microsoft.AspNetCore.Components;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
public class ReaderNavigationService : IReaderNavigationService public class ReaderNavigationService : IReaderNavigationService
{ {
private readonly NavigationManager _navigationManager;
public ReaderNavigationService(NavigationManager navigationManager)
{
_navigationManager = navigationManager;
}
public int CurrentChapterIndex { get; private set; } = 0; public int CurrentChapterIndex { get; private set; } = 0;
public int TotalChapters { get; private set; } = 1; public int TotalChapters { get; private set; } = 1;
public string ChapterTitle { get; private set; } = "Loading..."; public string ChapterTitle { get; private set; } = "Loading...";
@@ -47,6 +55,11 @@ public class ReaderNavigationService : IReaderNavigationService
} }
} }
public void NavigateToBook(Guid bookId)
{
_navigationManager.NavigateTo($"/reader/{bookId}");
}
private async Task NotifyNavigationChangedAsync() private async Task NotifyNavigationChangedAsync()
{ {
var handlers = OnNavigationChanged?.GetInvocationList(); var handlers = OnNavigationChanged?.GetInvocationList();
+6 -2
View File
@@ -68,6 +68,10 @@ public class ThrowingEmbeddingGenerator : IEmbeddingGenerator<string, Embedding<
public class ThrowingBookStorageService : IBookStorageService public class ThrowingBookStorageService : IBookStorageService
{ {
public Task<string> SaveEbookAsync(byte[] data, string fileName) => throw new NotSupportedException(); private const string ErrorMessage = "File storage operations are not supported in the WASM client. Use the API endpoint for ingestion.";
public Task<string?> SaveCoverAsync(byte[] data, string fileName) => throw new NotSupportedException();
public Task<string> SaveEbookAsync(byte[] data, string fileName) => throw new NotSupportedException(ErrorMessage);
public Task<string> SaveEbookAsync(Stream data, string fileName) => throw new NotSupportedException(ErrorMessage);
public Task<string?> SaveCoverAsync(byte[] data, string fileName) => throw new NotSupportedException(ErrorMessage);
public Task<string?> SaveCoverAsync(Stream data, string fileName) => throw new NotSupportedException(ErrorMessage);
} }
-1
View File
@@ -67,7 +67,6 @@ builder.Services.AddHttpClient("NexusAPI", (sp, client) =>
}); });
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI")); builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IIdentityService, NexusReader.Web.Services.ServerIdentityService>(); builder.Services.AddScoped<IIdentityService, NexusReader.Web.Services.ServerIdentityService>();
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();