From d5c2952becc66e2186ebc434fefe19cefb49c0b7 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Tue, 12 May 2026 18:19:07 +0000 Subject: [PATCH] feat(ingestion): implement hybrid metadata verification form #34 (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description This PR implements **Issue #34: [UI/UX] Implement Hybrid Metadata Verification Form in Ingestion Modal**. ### Key Changes - **Metadata Verification State**: Introduced a new state in `BookIngestionModal.razor` allowing users to edit `Title` and `Author` before final ingestion. - **Cover Image Preview**: Added a high-fidelity cover preview with a CSS-based glowing placeholder fallback for books without embedded covers. - **Ingestion Pipeline**: - Implemented `IngestEbookCommand` and `IngestEbookCommandHandler`. - Added `IBookStorageService` and its implementation for managing EPUB and cover file storage. - Exposed `POST /api/library/ingest` Minimal API endpoint with `.DisableAntiforgery()` to handle client-side JSON uploads. - **Stability Fixes**: - Resolved DI validation errors in the WASM client by providing a dummy `IBookStorageService` registration. - Adjusted Kestrel request limits to handle large EPUB payloads (up to 100MB). - Corrected middleware ordering to ensure Antiforgery works correctly with Authentication. ### Verification - Solution builds successfully. - Manual verification of modal state transitions and API ingestion logic. Closes #34. --------- Co-authored-by: Marek Jasiński Reviewed-on: https://git.archimap.cloud/mjasin/Nexus.Reader/pulls/41 Reviewed-by: Marek Jaisński Co-authored-by: Antigravity Co-committed-by: Antigravity --- .../Services/IBookStorageService.cs | 29 ++++ .../Commands/Library/IngestEbookCommand.cs | 21 +++ .../Library/IngestEbookCommandHandler.cs | 85 ++++++++++++ .../Commands/Library/IngestEbookRequest.cs | 8 ++ .../Queries/Reader/LocalEpubMetadata.cs | 25 +++- .../DependencyInjection.cs | 1 + .../Services/BookStorageService.cs | 71 ++++++++++ .../Services/EpubService.cs | 2 +- .../Organisms/BookIngestionModal.razor | 110 +++++++++++++-- .../Organisms/BookIngestionModal.razor.css | 130 +++++++++++++++++- .../Services/IReaderNavigationService.cs | 5 + .../Services/ReaderNavigationService.cs | 13 ++ src/NexusReader.Web.Client/Program.cs | 11 ++ .../Services/WasmEpubService.cs | 2 +- src/NexusReader.Web/Program.cs | 44 +++++- 15 files changed, 533 insertions(+), 24 deletions(-) create mode 100644 src/NexusReader.Application/Abstractions/Services/IBookStorageService.cs create mode 100644 src/NexusReader.Application/Commands/Library/IngestEbookCommand.cs create mode 100644 src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs create mode 100644 src/NexusReader.Application/Commands/Library/IngestEbookRequest.cs create mode 100644 src/NexusReader.Infrastructure/Services/BookStorageService.cs diff --git a/src/NexusReader.Application/Abstractions/Services/IBookStorageService.cs b/src/NexusReader.Application/Abstractions/Services/IBookStorageService.cs new file mode 100644 index 0000000..a45b420 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IBookStorageService.cs @@ -0,0 +1,29 @@ +namespace NexusReader.Application.Abstractions.Services; + +/// +/// Service for managing ebook and cover file storage. +/// +public interface IBookStorageService +{ + /// + /// Saves an ebook file and returns its relative path/URL. + /// + Task SaveEbookAsync(byte[] data, string fileName); + + /// + /// Saves an ebook file using a stream and returns its relative path/URL. + /// + Task SaveEbookAsync(Stream data, string fileName); + + /// + /// Saves a cover image and returns its relative path/URL. + /// Returns null if no cover data is provided. + /// + Task SaveCoverAsync(byte[] data, string fileName); + + /// + /// Saves a cover image using a stream and returns its relative path/URL. + /// Returns null if no cover data is provided. + /// + Task SaveCoverAsync(Stream data, string fileName); +} diff --git a/src/NexusReader.Application/Commands/Library/IngestEbookCommand.cs b/src/NexusReader.Application/Commands/Library/IngestEbookCommand.cs new file mode 100644 index 0000000..7026e25 --- /dev/null +++ b/src/NexusReader.Application/Commands/Library/IngestEbookCommand.cs @@ -0,0 +1,21 @@ +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Commands.Library; + +/// +/// Command to ingest a new ebook into the library. +/// +/// The title of the book. +/// The name of the author. +/// The raw bytes of the cover image (optional). +/// The raw bytes of the EPUB file. +/// The ID of the user owning the book. +/// The tenant ID for multi-tenant isolation. Defaults to "global" for single-tenant or default usage. +public record IngestEbookCommand( + string Title, + string AuthorName, + byte[]? CoverImage, + byte[] EpubData, + string UserId, + string TenantId = "global" +) : ICommand; diff --git a/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs new file mode 100644 index 0000000..0b2727d --- /dev/null +++ b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs @@ -0,0 +1,85 @@ +using FluentResults; +using MediatR; +using Microsoft.EntityFrameworkCore; +using NexusReader.Application.Abstractions.Messaging; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Data.Persistence; +using NexusReader.Domain.Entities; + +namespace NexusReader.Application.Commands.Library; + +public class IngestEbookCommandHandler : IRequestHandler> +{ + private readonly IDbContextFactory _dbContextFactory; + private readonly IBookStorageService _storageService; + + public IngestEbookCommandHandler( + IDbContextFactory dbContextFactory, + IBookStorageService storageService) + { + _dbContextFactory = dbContextFactory; + _storageService = storageService; + } + + public async Task> Handle(IngestEbookCommand request, CancellationToken cancellationToken) + { + using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + string epubPath; + string? coverUrl; + + try + { + // 1. Save Files + epubPath = await _storageService.SaveEbookAsync(request.EpubData, $"{request.Title}.epub"); + coverUrl = request.CoverImage != null && request.CoverImage.Length > 0 + ? await _storageService.SaveCoverAsync(request.CoverImage, $"{request.Title}_cover.jpg") + : null; + } + catch (Exception ex) + { + return Result.Fail(new Error($"Storage failure: {ex.Message}").CausedBy(ex)); + } + + try + { + // 2. Resolve Author + var authorName = string.IsNullOrWhiteSpace(request.AuthorName) ? "Unknown Author" : request.AuthorName.Trim(); + + // Use case-insensitive comparison + var author = await context.Authors + .FirstOrDefaultAsync(a => a.Name.ToLower() == authorName.ToLower(), cancellationToken); + + if (author == null) + { + author = new Author { Name = authorName }; + context.Authors.Add(author); + } + + // 3. Create Ebook + var ebook = new Ebook + { + Title = request.Title, + Author = author, + FilePath = epubPath, // Relative URL from wwwroot + CoverUrl = coverUrl, + UserId = request.UserId, + TenantId = request.TenantId, + AddedDate = DateTime.UtcNow + }; + + context.Ebooks.Add(ebook); + await context.SaveChangesAsync(cancellationToken); + + return Result.Ok(ebook.Id); + } + catch (DbUpdateException ex) + { + return Result.Fail(new Error($"Database error during ingestion: {ex.Message}").CausedBy(ex)); + } + catch (Exception ex) + { + return Result.Fail(new Error($"Unexpected error during ingestion: {ex.Message}").CausedBy(ex)); + } + } +} diff --git a/src/NexusReader.Application/Commands/Library/IngestEbookRequest.cs b/src/NexusReader.Application/Commands/Library/IngestEbookRequest.cs new file mode 100644 index 0000000..16adfbc --- /dev/null +++ b/src/NexusReader.Application/Commands/Library/IngestEbookRequest.cs @@ -0,0 +1,8 @@ +namespace NexusReader.Application.Commands.Library; + +public record IngestEbookRequest( + string Title, + string AuthorName, + string? CoverImageBase64, + string EpubDataBase64 +); diff --git a/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs b/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs index be21c38..f45a7dd 100644 --- a/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs +++ b/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs @@ -1,7 +1,22 @@ namespace NexusReader.Application.Queries.Reader; -public record LocalEpubMetadata( - string Title, - string Author, - byte[]? CoverImage = null -); +/// +/// Represents metadata extracted from a local EPUB file. +/// +public record LocalEpubMetadata +{ + /// + /// The title of the book. + /// + public string Title { get; set; } = string.Empty; + + /// + /// The author(s) of the book. + /// + public string Author { get; set; } = string.Empty; + + /// + /// The raw bytes of the cover image, if available. + /// + public byte[]? CoverImage { get; set; } +} diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 46d4fc4..3484315 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -75,6 +75,7 @@ public static class DependencyInjection services.AddScoped(); services.AddTransient(); services.AddTransient(); + services.AddSingleton(); services.AddAuthorizationCore(options => { diff --git a/src/NexusReader.Infrastructure/Services/BookStorageService.cs b/src/NexusReader.Infrastructure/Services/BookStorageService.cs new file mode 100644 index 0000000..58ec6ca --- /dev/null +++ b/src/NexusReader.Infrastructure/Services/BookStorageService.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Hosting; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.Infrastructure.Services; + +/// +/// Infrastructure implementation of book storage using local filesystem. +/// All paths returned are relative to the web root. +/// +public class BookStorageService : IBookStorageService +{ + private readonly IWebHostEnvironment _environment; + + public BookStorageService(IWebHostEnvironment environment) + { + _environment = environment; + } + + public async Task SaveEbookAsync(byte[] data, string fileName) + { + using var stream = new MemoryStream(data); + return await SaveEbookAsync(stream, fileName); + } + + public async Task SaveEbookAsync(Stream data, string fileName) + { + var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads"); + EnsureDirectoryExists(uploadsFolder); + + var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; + var filePath = Path.Combine(uploadsFolder, uniqueFileName); + + using (var fileStream = new FileStream(filePath, FileMode.Create)) + { + await data.CopyToAsync(fileStream); + } + + return Path.Combine("uploads", uniqueFileName); + } + + public async Task SaveCoverAsync(byte[] data, string fileName) + { + if (data == null || data.Length == 0) return null; + using var stream = new MemoryStream(data); + return await SaveCoverAsync(stream, fileName); + } + + public async Task SaveCoverAsync(Stream data, string fileName) + { + var coversFolder = Path.Combine(_environment.WebRootPath, "covers"); + EnsureDirectoryExists(coversFolder); + + var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; + var filePath = Path.Combine(coversFolder, uniqueFileName); + + using (var fileStream = new FileStream(filePath, FileMode.Create)) + { + await data.CopyToAsync(fileStream); + } + + return Path.Combine("covers", uniqueFileName); + } + + private void EnsureDirectoryExists(string path) + { + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + } +} diff --git a/src/NexusReader.Infrastructure/Services/EpubService.cs b/src/NexusReader.Infrastructure/Services/EpubService.cs index a473e9e..dea9445 100644 --- a/src/NexusReader.Infrastructure/Services/EpubService.cs +++ b/src/NexusReader.Infrastructure/Services/EpubService.cs @@ -228,7 +228,7 @@ public class EpubMetadataExtractor : IEpubMetadataExtractor var title = bookRef.Title ?? "Unknown Title"; var author = bookRef.Author ?? "Unknown Author"; byte[]? cover = await bookRef.ReadCoverAsync(); - return Result.Ok(new LocalEpubMetadata(title, author, cover)); + return Result.Ok(new LocalEpubMetadata { Title = title, Author = author, CoverImage = cover }); } catch (Exception ex) { diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor index 41c3718..6caa2b0 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor @@ -1,8 +1,13 @@ @using Microsoft.AspNetCore.Components.Forms @using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Queries.Reader +@using NexusReader.Application.Commands.Library +@using System.Net.Http.Json @inject IEpubMetadataExtractor MetadataExtractor @inject ILogger Logger +@inject HttpClient Http +@inject IReaderNavigationService ReaderNavigation +@inject IJSRuntime JSRuntime @implements IAsyncDisposable @if (IsOpen) @@ -24,22 +29,48 @@ -