From 94fd7cf5c1e05d057d97b94412dec9f261d2a971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Tue, 12 May 2026 14:55:34 +0200 Subject: [PATCH 1/2] feat(ingestion): implement hybrid metadata verification form in ingestion modal #34 --- .../Services/IBookStorageService.cs | 7 + .../Commands/Library/IngestEbookCommand.cs | 12 ++ .../Library/IngestEbookCommandHandler.cs | 71 ++++++++++ .../Commands/Library/IngestEbookRequest.cs | 8 ++ .../Queries/Reader/LocalEpubMetadata.cs | 11 +- .../DependencyInjection.cs | 1 + .../Services/BookStorageService.cs | 50 +++++++ .../Services/EpubService.cs | 2 +- .../Organisms/BookIngestionModal.razor | 111 +++++++++++++-- .../Organisms/BookIngestionModal.razor.css | 128 ++++++++++++++++++ src/NexusReader.Web.Client/Program.cs | 7 + .../Services/WasmEpubService.cs | 2 +- src/NexusReader.Web/Program.cs | 43 +++++- 13 files changed, 431 insertions(+), 22 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..eb39b53 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IBookStorageService.cs @@ -0,0 +1,7 @@ +namespace NexusReader.Application.Abstractions.Services; + +public interface IBookStorageService +{ + Task SaveEbookAsync(byte[] data, string fileName); + Task SaveCoverAsync(byte[] 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..18c11c4 --- /dev/null +++ b/src/NexusReader.Application/Commands/Library/IngestEbookCommand.cs @@ -0,0 +1,12 @@ +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Commands.Library; + +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..f01dcd3 --- /dev/null +++ b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs @@ -0,0 +1,71 @@ +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) + { + try + { + using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + // 1. Save Files + var epubPath = await _storageService.SaveEbookAsync(request.EpubData, $"{request.Title}.epub"); + var coverUrl = request.CoverImage != null && request.CoverImage.Length > 0 + ? await _storageService.SaveCoverAsync(request.CoverImage, $"{request.Title}_cover.jpg") + : null; + + // 2. Resolve Author + var authorName = string.IsNullOrWhiteSpace(request.AuthorName) ? "Unknown Author" : request.AuthorName.Trim(); + 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); + // 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 + var ebook = new Ebook + { + Title = request.Title, + Author = author, + FilePath = epubPath, + 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 (Exception ex) + { + return Result.Fail(new Error("Failed to ingest ebook").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..bfaee6a 100644 --- a/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs +++ b/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs @@ -1,7 +1,8 @@ namespace NexusReader.Application.Queries.Reader; -public record LocalEpubMetadata( - string Title, - string Author, - byte[]? CoverImage = null -); +public record LocalEpubMetadata +{ + public string Title { get; set; } = string.Empty; + public string Author { get; set; } = string.Empty; + public byte[]? CoverImage { get; set; } +} diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 46d4fc4..2779061 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.AddScoped(); 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..caaed22 --- /dev/null +++ b/src/NexusReader.Infrastructure/Services/BookStorageService.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Hosting; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.Infrastructure.Services; + +public class BookStorageService : IBookStorageService +{ + private readonly IWebHostEnvironment _environment; + + public BookStorageService(IWebHostEnvironment environment) + { + _environment = environment; + } + + public async Task SaveEbookAsync(byte[] data, string fileName) + { + var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads"); + if (!Directory.Exists(uploadsFolder)) + { + Directory.CreateDirectory(uploadsFolder); + } + + var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; + var filePath = Path.Combine(uploadsFolder, uniqueFileName); + + await File.WriteAllBytesAsync(filePath, data); + + // 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); + } + + public async Task SaveCoverAsync(byte[] data, string fileName) + { + if (data == null || data.Length == 0) return null; + + var coversFolder = Path.Combine(_environment.WebRootPath, "covers"); + if (!Directory.Exists(coversFolder)) + { + Directory.CreateDirectory(coversFolder); + } + + var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; + var filePath = Path.Combine(coversFolder, uniqueFileName); + + await File.WriteAllBytesAsync(filePath, data); + + return Path.Combine("covers", uniqueFileName); + } +} 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..b9d87c0 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 @@ -