refactor: address PR review comments for ingestion workflow
This commit is contained in:
@@ -1,7 +1,29 @@
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing ebook and cover file storage.
|
||||
/// </summary>
|
||||
public interface IBookStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Saves an ebook file and returns its relative path/URL.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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(
|
||||
string Title,
|
||||
string AuthorName,
|
||||
|
||||
@@ -23,18 +23,30 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
||||
|
||||
public async Task<Result<Guid>> Handle(IngestEbookCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
string epubPath;
|
||||
string? coverUrl;
|
||||
|
||||
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
|
||||
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);
|
||||
|
||||
@@ -42,8 +54,6 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
||||
{
|
||||
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
|
||||
@@ -51,7 +61,7 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
||||
{
|
||||
Title = request.Title,
|
||||
Author = author,
|
||||
FilePath = epubPath,
|
||||
FilePath = epubPath, // Relative URL from wwwroot
|
||||
CoverUrl = coverUrl,
|
||||
UserId = request.UserId,
|
||||
TenantId = request.TenantId,
|
||||
@@ -63,9 +73,13 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
||||
|
||||
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("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;
|
||||
|
||||
/// <summary>
|
||||
/// Represents metadata extracted from a local EPUB file.
|
||||
/// </summary>
|
||||
public record LocalEpubMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// The title of the book.
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The author(s) of the book.
|
||||
/// </summary>
|
||||
public string Author { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The raw bytes of the cover image, if available.
|
||||
/// </summary>
|
||||
public byte[]? CoverImage { get; set; }
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
||||
services.AddTransient<IEpubReader, EpubReaderService>();
|
||||
services.AddTransient<IEpubMetadataExtractor, EpubMetadataExtractor>();
|
||||
services.AddScoped<IBookStorageService, BookStorageService>();
|
||||
services.AddSingleton<IBookStorageService, BookStorageService>();
|
||||
|
||||
services.AddAuthorizationCore(options =>
|
||||
{
|
||||
|
||||
@@ -3,6 +3,10 @@ using NexusReader.Application.Abstractions.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
|
||||
{
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
@@ -13,38 +17,55 @@ public class BookStorageService : IBookStorageService
|
||||
}
|
||||
|
||||
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");
|
||||
if (!Directory.Exists(uploadsFolder))
|
||||
{
|
||||
Directory.CreateDirectory(uploadsFolder);
|
||||
}
|
||||
EnsureDirectoryExists(uploadsFolder);
|
||||
|
||||
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task<string?> 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<string?> SaveCoverAsync(Stream data, string fileName)
|
||||
{
|
||||
var coversFolder = Path.Combine(_environment.WebRootPath, "covers");
|
||||
if (!Directory.Exists(coversFolder))
|
||||
{
|
||||
Directory.CreateDirectory(coversFolder);
|
||||
}
|
||||
EnsureDirectoryExists(coversFolder);
|
||||
|
||||
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
||||
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);
|
||||
}
|
||||
|
||||
private void EnsureDirectoryExists(string path)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,8 +216,7 @@
|
||||
if (result != null)
|
||||
{
|
||||
await CloseModal();
|
||||
// Navigate to the newly added book
|
||||
// ReaderNavigation.NavigateToBook(result.Id);
|
||||
ReaderNavigation.NavigateToBook(result.Id);
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
@@ -291,7 +291,7 @@
|
||||
position: absolute;
|
||||
width: 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;
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@
|
||||
color: var(--nexus-neon, #00ffaa);
|
||||
opacity: 0.5;
|
||||
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 {
|
||||
@@ -341,13 +341,13 @@
|
||||
|
||||
.error-message {
|
||||
margin-top: 1rem;
|
||||
color: #ff5555;
|
||||
color: var(--nexus-error, #ff5555);
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
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: 1px solid rgba(255, 85, 85, 0.2);
|
||||
border: 1px solid var(--nexus-error-alpha-deep, rgba(255, 85, 85, 0.2));
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
|
||||
@@ -12,4 +12,9 @@ public interface IReaderNavigationService
|
||||
Task GoToNextChapter();
|
||||
Task GoToPreviousChapter();
|
||||
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 Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public class ReaderNavigationService : IReaderNavigationService
|
||||
{
|
||||
private readonly NavigationManager _navigationManager;
|
||||
|
||||
public ReaderNavigationService(NavigationManager navigationManager)
|
||||
{
|
||||
_navigationManager = navigationManager;
|
||||
}
|
||||
|
||||
public int CurrentChapterIndex { get; private set; } = 0;
|
||||
public int TotalChapters { get; private set; } = 1;
|
||||
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()
|
||||
{
|
||||
var handlers = OnNavigationChanged?.GetInvocationList();
|
||||
|
||||
@@ -68,6 +68,10 @@ public class ThrowingEmbeddingGenerator : IEmbeddingGenerator<string, Embedding<
|
||||
|
||||
public class ThrowingBookStorageService : IBookStorageService
|
||||
{
|
||||
public Task<string> SaveEbookAsync(byte[] data, string fileName) => throw new NotSupportedException();
|
||||
public Task<string?> SaveCoverAsync(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> 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);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ builder.Services.AddHttpClient("NexusAPI", (sp, client) =>
|
||||
});
|
||||
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<IIdentityService, NexusReader.Web.Services.ServerIdentityService>();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user