feat: Ingestion Pipeline Stabilization and WASM Service Proxies (#42)
This PR stabilizes the Nexus Ingestion Engine by implementing functional service proxies for the Blazor WASM client and refining the backend infrastructure for real-time progress tracking and database compatibility. ### Key Changes - **Infrastructure Stabilization**: - Implemented production-grade `EbookRepository` with PostgreSQL `EF.Functions.ILike` support. - Enforced `IsReadyForReading = false` state for newly added ebooks (resolves #35). - Updated `SignalRSyncBroadcaster` to support targeted user messaging and ingestion-specific progress updates (resolves #37). - **WASM Client Functional Proxies**: - Replaced "Throwing" dummy services with `WasmEbookRepository`, `WasmSyncBroadcaster`, `WasmBookStorageService`, and `WasmEmbeddingGenerator`. - These services proxy requests to the backend via a new set of Minimal API endpoints in `NexusReader.Web`. - **Domain Refinement**: - Added `IsReadyForReading` flag to the `Ebook` entity to manage background AI processing states. ### Related Issues - Fixes #35 - Fixes #36 - Fixes #37 --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #42 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #42.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
namespace NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for broadcasting real-time sync events to connected clients.
|
||||
/// Defined in Application to prevent a direct dependency on SignalR in Application layer handlers.
|
||||
/// </summary>
|
||||
public interface ISyncBroadcaster
|
||||
{
|
||||
/// <summary>
|
||||
/// Broadcasts a reading progress update to all devices belonging to the specified user,
|
||||
/// optionally excluding the originating connection.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user whose other devices should be notified.</param>
|
||||
/// <param name="pageId">The block/page ID the user has reached.</param>
|
||||
/// <param name="timestamp">The server-side UTC timestamp of the update.</param>
|
||||
/// <param name="excludedConnectionId">SignalR connection ID to exclude (the sender's device).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task BroadcastProgressAsync(
|
||||
string userId,
|
||||
string pageId,
|
||||
DateTime timestamp,
|
||||
string? excludedConnectionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts real-time ingestion status updates to a specific user.
|
||||
/// This is used by background workers to provide feedback during AI-intensive processing.
|
||||
/// </summary>
|
||||
/// <param name="userId">The ID of the user who owns the ingestion request.</param>
|
||||
/// <param name="message">A human-readable status message (e.g., "Parsing chapters...").</param>
|
||||
/// <param name="progress">Progress percentage (0.0 to 1.0).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task BroadcastIngestionProgressAsync(
|
||||
string userId,
|
||||
string message,
|
||||
double progress,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for Ebook and Author persistence operations.
|
||||
/// Defined in the Application layer to avoid a direct dependency on EF Core.
|
||||
/// </summary>
|
||||
public interface IEbookRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds an author by name using a case-insensitive comparison.
|
||||
/// </summary>
|
||||
Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new author to the repository (staged, not yet persisted).
|
||||
/// </summary>
|
||||
void AddAuthor(Author author);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new ebook to the repository (staged, not yet persisted).
|
||||
/// </summary>
|
||||
void AddEbook(Ebook ebook);
|
||||
|
||||
/// <summary>
|
||||
/// Persists all staged changes to the underlying store.
|
||||
/// </summary>
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -3,7 +3,21 @@ using NexusReader.Application.Queries.Reader;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and parses EPUB content for a specific ebook and chapter.
|
||||
/// </summary>
|
||||
public interface IEpubReader
|
||||
{
|
||||
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex, string? userId = null);
|
||||
/// <summary>
|
||||
/// Retrieves the content blocks for a given chapter of the specified ebook.
|
||||
/// </summary>
|
||||
/// <param name="ebookId">The unique ID of the ebook to read.</param>
|
||||
/// <param name="chapterIndex">Zero-based chapter index.</param>
|
||||
/// <param name="userId">The authenticated user's ID (used for tenant isolation in the DB lookup).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<Result<ReaderPageViewModel>> GetEpubContentAsync(
|
||||
Guid ebookId,
|
||||
int chapterIndex,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
namespace NexusReader.Application.Commands.Library;
|
||||
|
||||
public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Result<Guid>>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly IEbookRepository _ebookRepository;
|
||||
private readonly IBookStorageService _storageService;
|
||||
|
||||
public IngestEbookCommandHandler(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
IEbookRepository ebookRepository,
|
||||
IBookStorageService storageService)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_ebookRepository = ebookRepository;
|
||||
_storageService = storageService;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(IngestEbookCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
string epubPath;
|
||||
string? coverUrl;
|
||||
|
||||
@@ -36,6 +33,10 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
||||
? await _storageService.SaveCoverAsync(request.CoverImage, $"{request.Title}_cover.jpg")
|
||||
: null;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Storage I/O failure: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Storage failure: {ex.Message}").CausedBy(ex));
|
||||
@@ -43,17 +44,16 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
||||
|
||||
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);
|
||||
// 2. Resolve Author (case-insensitive via repository)
|
||||
var authorName = string.IsNullOrWhiteSpace(request.AuthorName)
|
||||
? "Unknown Author"
|
||||
: request.AuthorName.Trim();
|
||||
|
||||
var author = await _ebookRepository.FindAuthorByNameAsync(authorName, cancellationToken);
|
||||
if (author == null)
|
||||
{
|
||||
author = new Author { Name = authorName };
|
||||
context.Authors.Add(author);
|
||||
_ebookRepository.AddAuthor(author);
|
||||
}
|
||||
|
||||
// 3. Create Ebook
|
||||
@@ -61,25 +61,21 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
||||
{
|
||||
Title = request.Title,
|
||||
Author = author,
|
||||
FilePath = epubPath, // Relative URL from wwwroot
|
||||
FilePath = epubPath,
|
||||
CoverUrl = coverUrl,
|
||||
UserId = request.UserId,
|
||||
TenantId = request.TenantId,
|
||||
AddedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Ebooks.Add(ebook);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
_ebookRepository.AddEbook(ebook);
|
||||
await _ebookRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(ebook.Id);
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
catch (Exception 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-23
@@ -1,30 +1,32 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Commands.Sync;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Infrastructure.RealTime;
|
||||
|
||||
namespace NexusReader.Infrastructure.Handlers;
|
||||
namespace NexusReader.Application.Commands.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="UpdateReadingProgressCommand"/>.
|
||||
/// Persists the user's reading position and broadcasts the update to other connected devices.
|
||||
/// </summary>
|
||||
public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly IHubContext<SyncHub> _hubContext;
|
||||
private readonly ISyncBroadcaster _broadcaster;
|
||||
|
||||
public UpdateReadingProgressCommandHandler(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
IHubContext<SyncHub> hubContext)
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
ISyncBroadcaster broadcaster)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_hubContext = hubContext;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
@@ -35,7 +37,6 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReading
|
||||
user.LastReadPageId = request.PageId;
|
||||
user.LastReadAt = now;
|
||||
|
||||
// Update specific Ebook progress
|
||||
var ebook = await context.Ebooks.FirstOrDefaultAsync(e => e.Id == request.EbookId, cancellationToken);
|
||||
if (ebook != null)
|
||||
{
|
||||
@@ -47,19 +48,13 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReading
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Broadcast to other devices
|
||||
var group = _hubContext.Clients.Group($"User_{request.UserId}");
|
||||
|
||||
if (!string.IsNullOrEmpty(request.ExcludedConnectionId))
|
||||
{
|
||||
await _hubContext.Clients
|
||||
.GroupExcept($"User_{request.UserId}", request.ExcludedConnectionId)
|
||||
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await group.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
|
||||
}
|
||||
// Broadcast to other devices via the abstracted broadcaster
|
||||
await _broadcaster.BroadcastProgressAsync(
|
||||
request.UserId,
|
||||
request.PageId,
|
||||
now,
|
||||
request.ExcludedConnectionId,
|
||||
cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
@@ -2,4 +2,13 @@ using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Queries.Reader;
|
||||
|
||||
public record GetReaderPageQuery(int ChapterIndex = 0, string? UserId = null) : IQuery<ReaderPageViewModel>;
|
||||
/// <summary>
|
||||
/// Query to retrieve a specific chapter of a user's ebook.
|
||||
/// </summary>
|
||||
/// <param name="EbookId">The ID of the ebook to read.</param>
|
||||
/// <param name="ChapterIndex">Zero-based chapter index.</param>
|
||||
/// <param name="UserId">The authenticated user's ID for tenant isolation.</param>
|
||||
public record GetReaderPageQuery(
|
||||
Guid EbookId,
|
||||
int ChapterIndex = 0,
|
||||
string? UserId = null) : IQuery<ReaderPageViewModel>;
|
||||
|
||||
@@ -6,15 +6,15 @@ namespace NexusReader.Application.Queries.Reader;
|
||||
|
||||
internal sealed class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQuery, ReaderPageViewModel>
|
||||
{
|
||||
private readonly IEpubReader _epubService;
|
||||
private readonly IEpubReader _epubReader;
|
||||
|
||||
public GetReaderPageQueryHandler(IEpubReader epubService)
|
||||
public GetReaderPageQueryHandler(IEpubReader epubReader)
|
||||
{
|
||||
_epubService = epubService;
|
||||
_epubReader = epubReader;
|
||||
}
|
||||
|
||||
public async Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
|
||||
public Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _epubService.GetEpubContentAsync(request.ChapterIndex, request.UserId);
|
||||
return _epubReader.GetEpubContentAsync(request.EbookId, request.ChapterIndex, request.UserId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,9 @@ namespace NexusReader.Data.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsReadyForReading")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LastChapter")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
+703
@@ -0,0 +1,703 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NexusReader.Data.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260513181743_AddEbookReadyFlag")]
|
||||
partial class AddEbookReadyFlag
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Authors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("AddedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("AuthorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsReadyForReading")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LastChapter")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<int>("LastChapterIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("LastReadDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Progress")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(768)");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("KnowledgeUnits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("RelationType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("SourceUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TargetUnitId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceUnitId");
|
||||
|
||||
b.HasIndex("TargetUnitId");
|
||||
|
||||
b.ToTable("KnowledgeUnitLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AITokensUsed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastAiActionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("LastReadAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastReadPageId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SubscriptionPlanId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionPlanId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CompletedDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("TotalQuestions")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("QuizResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
|
||||
{
|
||||
b.Property<string>("ContentHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("JsonData")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ModelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("OriginalText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PromptVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Vector>("Vector")
|
||||
.HasColumnType("vector(1536)");
|
||||
|
||||
b.HasKey("ContentHash");
|
||||
|
||||
b.HasIndex("ContentHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("SemanticKnowledgeCache");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AITokenLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsUnlimitedTokens")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("PlanName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("StripeProductId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlanName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionPlans");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
AITokenLimit = 5000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 0m,
|
||||
PlanName = "Free",
|
||||
StripeProductId = "prod_Free789"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
AITokenLimit = 10000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 9.99m,
|
||||
PlanName = "Basic",
|
||||
StripeProductId = "prod_basic_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
AITokenLimit = 50000,
|
||||
IsUnlimitedTokens = false,
|
||||
MonthlyPrice = 19.99m,
|
||||
PlanName = "Pro",
|
||||
StripeProductId = "prod_pro_placeholder"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
AITokenLimit = 1000000000,
|
||||
IsUnlimitedTokens = true,
|
||||
MonthlyPrice = 99.99m,
|
||||
PlanName = "Enterprise",
|
||||
StripeProductId = "prod_enterprise_placeholder"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("AuthorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("Ebooks")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Author");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
|
||||
.WithMany("OutgoingLinks")
|
||||
.HasForeignKey("SourceUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
|
||||
.WithMany("IncomingLinks")
|
||||
.HasForeignKey("TargetUnitId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SourceUnit");
|
||||
|
||||
b.Navigation("TargetUnit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubscriptionPlanId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SubscriptionPlan");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
|
||||
{
|
||||
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
|
||||
.WithMany("QuizResults")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
|
||||
{
|
||||
b.Navigation("IncomingLinks");
|
||||
|
||||
b.Navigation("OutgoingLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
|
||||
{
|
||||
b.Navigation("Ebooks");
|
||||
|
||||
b.Navigation("QuizResults");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NexusReader.Data.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEbookReadyFlag : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsReadyForReading",
|
||||
table: "Ebooks",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsReadyForReading",
|
||||
table: "Ebooks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ public class Ebook
|
||||
public string? LastChapter { get; set; }
|
||||
|
||||
public int LastChapterIndex { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the ebook has been processed by the AI ingestion engine
|
||||
/// and is ready for reading (Knowledge Units generated).
|
||||
/// </summary>
|
||||
public bool IsReadyForReading { get; set; } = false;
|
||||
|
||||
// Relationship to NexusUser
|
||||
[Required]
|
||||
|
||||
@@ -7,7 +7,11 @@ using GeminiDotnet;
|
||||
using GeminiDotnet.Extensions.AI;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Infrastructure.RealTime;
|
||||
using NexusReader.Infrastructure.Services;
|
||||
using NexusReader.Infrastructure.Configuration;
|
||||
using Polly;
|
||||
@@ -27,12 +31,21 @@ public static class DependencyInjection
|
||||
if (!string.IsNullOrEmpty(pgConnectionString))
|
||||
{
|
||||
services.AddDbContextFactory<AppDbContext>(options =>
|
||||
options.UseNpgsql(pgConnectionString, x => x.UseVector()),
|
||||
ServiceLifetime.Scoped);
|
||||
|
||||
// Also register a scoped DbContext for repositories that need it
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseNpgsql(pgConnectionString, x => x.UseVector()));
|
||||
}
|
||||
else
|
||||
{
|
||||
var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db";
|
||||
services.AddDbContextFactory<AppDbContext>(options =>
|
||||
options.UseSqlite(sqliteConnectionString),
|
||||
ServiceLifetime.Scoped);
|
||||
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseSqlite(sqliteConnectionString));
|
||||
}
|
||||
|
||||
@@ -40,8 +53,6 @@ public static class DependencyInjection
|
||||
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
||||
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
||||
|
||||
Console.WriteLine($"[Infrastructure] AI Configured: Model={aiSettings.Model}, KeyPresent={!string.IsNullOrWhiteSpace(aiSettings.ApiKey) && aiSettings.ApiKey != "PLACEHOLDER"}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
||||
{
|
||||
Console.WriteLine("[Infrastructure] WARNING: AI API Key is missing or placeholder!");
|
||||
@@ -51,7 +62,7 @@ public static class DependencyInjection
|
||||
{
|
||||
builder.AddRetry(new RetryStrategyOptions
|
||||
{
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex =>
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex =>
|
||||
ex.Message.Contains("429") || ex.Message.Contains("Too Many Requests") || ex.Message.Contains("quota")),
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
UseJitter = true,
|
||||
@@ -60,10 +71,10 @@ public static class DependencyInjection
|
||||
});
|
||||
});
|
||||
|
||||
services.AddChatClient(new GeminiChatClient(new GeminiClientOptions
|
||||
{
|
||||
ApiKey = aiSettings.ApiKey,
|
||||
ModelId = aiSettings.Model
|
||||
services.AddChatClient(new GeminiChatClient(new GeminiClientOptions
|
||||
{
|
||||
ApiKey = aiSettings.ApiKey,
|
||||
ModelId = aiSettings.Model
|
||||
}));
|
||||
|
||||
services.AddEmbeddingGenerator(new GeminiEmbeddingGenerator(new GeminiClientOptions
|
||||
@@ -72,10 +83,20 @@ public static class DependencyInjection
|
||||
ModelId = aiSettings.EmbeddingModel ?? "text-embedding-004"
|
||||
}));
|
||||
|
||||
// Application-layer service implementations
|
||||
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
||||
services.AddTransient<IEpubReader, EpubReaderService>();
|
||||
services.AddTransient<IEpubMetadataExtractor, EpubMetadataExtractor>();
|
||||
services.AddSingleton<IBookStorageService, BookStorageService>();
|
||||
|
||||
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
|
||||
// that is environment-specific and incompatible with Singleton lifetime in MAUI.
|
||||
services.AddScoped<IBookStorageService, BookStorageService>();
|
||||
|
||||
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
|
||||
services.AddScoped<IEbookRepository, EbookRepository>();
|
||||
|
||||
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
||||
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
||||
|
||||
services.AddAuthorizationCore(options =>
|
||||
{
|
||||
@@ -83,7 +104,6 @@ public static class DependencyInjection
|
||||
});
|
||||
|
||||
services.AddScoped<IAuthorizationHandler, ProUserHandler>();
|
||||
|
||||
services.AddScoped<IInfrastructureMarker, InfrastructureMarker>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IEbookRepository"/>.
|
||||
/// Uses a scoped <see cref="AppDbContext"/> created via the factory for long-running operations.
|
||||
/// </summary>
|
||||
internal sealed class EbookRepository : IEbookRepository
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public EbookRepository(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Use PostgreSQL ILike for case-insensitive searching if on Npgsql provider,
|
||||
// otherwise fallback to string comparison.
|
||||
if (_context.Database.IsNpgsql())
|
||||
{
|
||||
return await _context.Authors
|
||||
.FirstOrDefaultAsync(a => EF.Functions.ILike(a.Name, name), cancellationToken);
|
||||
}
|
||||
|
||||
return await _context.Authors
|
||||
.FirstOrDefaultAsync(
|
||||
a => a.Name.ToLower() == name.ToLower(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddAuthor(Author author) => _context.Authors.Add(author);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddEbook(Ebook ebook)
|
||||
{
|
||||
// Explicitly set the readiness flag to false upon addition
|
||||
ebook.IsReadyForReading = false;
|
||||
_context.Ebooks.Add(ebook);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Infrastructure.RealTime;
|
||||
|
||||
namespace NexusReader.Infrastructure.RealTime;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR implementation of <see cref="ISyncBroadcaster"/>.
|
||||
/// Uses <see cref="IHubContext{SyncHub}"/> to push progress updates to all of a user's connected devices.
|
||||
/// </summary>
|
||||
internal sealed class SignalRSyncBroadcaster : ISyncBroadcaster
|
||||
{
|
||||
private readonly IHubContext<SyncHub> _hubContext;
|
||||
|
||||
public SignalRSyncBroadcaster(IHubContext<SyncHub> hubContext)
|
||||
{
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task BroadcastProgressAsync(
|
||||
string userId,
|
||||
string pageId,
|
||||
DateTime timestamp,
|
||||
string? excludedConnectionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Using Clients.User(userId) targeted broadcasting.
|
||||
// This pushes to all of a user's connected devices across all sessions.
|
||||
if (!string.IsNullOrEmpty(excludedConnectionId))
|
||||
{
|
||||
await _hubContext.Clients
|
||||
.User(userId)
|
||||
.SendAsync("ProgressUpdated", pageId, timestamp, cancellationToken: cancellationToken);
|
||||
|
||||
// Note: SignalR HubContext doesn't easily support 'Except' when using .User(id)
|
||||
// from outside the Hub itself without custom IUserIdProvider.
|
||||
// If strict exclusion is needed, we'd use groups, but requirements mandate .User(userId).
|
||||
}
|
||||
else
|
||||
{
|
||||
await _hubContext.Clients
|
||||
.User(userId)
|
||||
.SendAsync("ProgressUpdated", pageId, timestamp, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task BroadcastIngestionProgressAsync(
|
||||
string userId,
|
||||
string message,
|
||||
double progress,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Pushes ingestion status (e.g., "Parsing chapters...") and progress (0.0-1.0)
|
||||
// directly to the user's active session components (like BookIngestionModal).
|
||||
await _hubContext.Clients
|
||||
.User(userId)
|
||||
.SendAsync("IngestionProgress", message, progress, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,9 @@ public class BookStorageService : IBookStorageService
|
||||
await data.CopyToAsync(fileStream);
|
||||
}
|
||||
|
||||
return Path.Combine("uploads", uniqueFileName);
|
||||
// Use forward-slash explicitly: Path.Combine produces backslashes on Windows
|
||||
// which would cause 404s when stored as web-relative paths.
|
||||
return $"uploads/{uniqueFileName}";
|
||||
}
|
||||
|
||||
public async Task<string?> SaveCoverAsync(byte[] data, string fileName)
|
||||
@@ -58,7 +60,7 @@ public class BookStorageService : IBookStorageService
|
||||
await data.CopyToAsync(fileStream);
|
||||
}
|
||||
|
||||
return Path.Combine("covers", uniqueFileName);
|
||||
return $"covers/{uniqueFileName}";
|
||||
}
|
||||
|
||||
private void EnsureDirectoryExists(string path)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.Queries.Reader;
|
||||
using VersOne.Epub;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata (title, author, cover image) from an EPUB stream without persisting anything.
|
||||
/// Used by the ingestion UI before the user confirms the upload.
|
||||
/// </summary>
|
||||
public class EpubMetadataExtractor : IEpubMetadataExtractor
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<LocalEpubMetadata>> ExtractMetadataAsync(Stream epubStream)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var bookRef = await EpubReader.OpenBookAsync(epubStream);
|
||||
var title = bookRef.Title ?? "Unknown Title";
|
||||
var author = bookRef.Author ?? "Unknown Author";
|
||||
byte[]? cover = await bookRef.ReadCoverAsync();
|
||||
return Result.Ok(new LocalEpubMetadata { Title = title, Author = author, CoverImage = cover });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Failed to extract EPUB metadata locally: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
-109
@@ -1,60 +1,64 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.Queries.Reader;
|
||||
using VersOne.Epub;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Domain.Entities;
|
||||
using VersOne.Epub;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and parses EPUB files from the storage path recorded in the database.
|
||||
/// </summary>
|
||||
public class EpubReaderService : IEpubReader
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private const string EpubPath = "wwwroot/assets/book.epub";
|
||||
private readonly ILogger<EpubReaderService> _logger;
|
||||
private const int WordThreshold = 1000;
|
||||
|
||||
public EpubReaderService(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
public EpubReaderService(
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
ILogger<EpubReaderService> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex, string? userId = null)
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<ReaderPageViewModel>> GetEpubContentAsync(
|
||||
Guid ebookId,
|
||||
int chapterIndex,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
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>();
|
||||
// 1. Resolve the file path from the database
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var currentDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
|
||||
while (currentDir != null)
|
||||
var ebook = await context.Ebooks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
e => e.Id == ebookId && (userId == null || e.UserId == userId),
|
||||
cancellationToken);
|
||||
|
||||
if (ebook == null)
|
||||
{
|
||||
var checkPath1 = Path.Combine(currentDir.FullName, relativePath);
|
||||
var checkPath2 = Path.Combine(currentDir.FullName, "src", "NexusReader.Web", 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))}");
|
||||
return Result.Fail($"Ebook '{ebookId}' not found for user '{userId}'.");
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
// FilePath is stored as a web-relative path (e.g. "uploads/guid_title.epub").
|
||||
// Resolve against the content root, then against the wwwroot sub-directory.
|
||||
var fullPath = ResolvePath(ebook.FilePath);
|
||||
if (fullPath == null || !File.Exists(fullPath))
|
||||
{
|
||||
return Result.Fail($"EPUB file at '{fullPath}' is not accessible or does not exist.");
|
||||
_logger.LogError("EPUB file for ebook {EbookId} not found at path '{FilePath}'.", ebookId, ebook.FilePath);
|
||||
return Result.Fail($"The EPUB file for this book could not be found on the server.");
|
||||
}
|
||||
|
||||
// 2. Parse the EPUB
|
||||
using var bookRef = await EpubReader.OpenBookAsync(fullPath);
|
||||
var readingOrder = bookRef.GetReadingOrder();
|
||||
|
||||
@@ -63,22 +67,20 @@ public class EpubReaderService : IEpubReader
|
||||
return Result.Fail("The EPUB has no readable content files in ReadingOrder.");
|
||||
}
|
||||
|
||||
// Ensure index is within bounds
|
||||
if (chapterIndex < 0 || chapterIndex >= readingOrder.Count)
|
||||
{
|
||||
chapterIndex = 0; // Default to first chapter
|
||||
chapterIndex = 0;
|
||||
}
|
||||
|
||||
var chapterRef = readingOrder[chapterIndex];
|
||||
|
||||
// Try to find a better title from navigation (TOC)
|
||||
var navigation = bookRef.GetNavigation();
|
||||
var chapterTitle = FindTitleInNavigation(navigation, chapterRef.FilePath)
|
||||
?? Path.GetFileNameWithoutExtension(chapterRef.FilePath)
|
||||
var chapterTitle = FindTitleInNavigation(navigation, chapterRef.FilePath)
|
||||
?? Path.GetFileNameWithoutExtension(chapterRef.FilePath)
|
||||
?? $"Chapter {chapterIndex + 1}";
|
||||
|
||||
var chapterContent = await chapterRef.ReadContentAsTextAsync();
|
||||
|
||||
// 3. Build content blocks
|
||||
var blocks = new List<ContentBlock>();
|
||||
int totalWordCount = 0;
|
||||
int blockCounter = 0;
|
||||
@@ -89,13 +91,11 @@ public class EpubReaderService : IEpubReader
|
||||
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++}"));
|
||||
@@ -103,58 +103,58 @@ public class EpubReaderService : IEpubReader
|
||||
}
|
||||
}
|
||||
|
||||
// End of chapter section trigger
|
||||
if (blocks.Any() && blocks.Last() is not AiActionTriggerBlock)
|
||||
{
|
||||
blocks.Add(CreateAiTrigger($"trigger-{blockCounter++}"));
|
||||
}
|
||||
|
||||
// Find the EbookId from DB for this file AND this user
|
||||
using var context = await _dbContextFactory.CreateDbContextAsync();
|
||||
var ebook = await context.Ebooks
|
||||
.Where(e => e.FilePath.Contains("book.epub") && (userId == null || e.UserId == userId))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
// Auto-provision if not found for this user (convenience for dev)
|
||||
if (ebook == null && !string.IsNullOrEmpty(userId))
|
||||
{
|
||||
var author = await context.Authors.FirstOrDefaultAsync() ?? new Author { Name = "Unknown Author" };
|
||||
ebook = new Ebook
|
||||
{
|
||||
Title = "Lives of the Most Excellent Painters, Sculptors, and Architects",
|
||||
FilePath = "wwwroot/assets/book.epub",
|
||||
UserId = userId,
|
||||
Author = author,
|
||||
TenantId = "global"
|
||||
};
|
||||
context.Ebooks.Add(ebook);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle, ebook?.Id ?? Guid.Empty));
|
||||
return Result.Ok(new ReaderPageViewModel(blocks, chapterIndex, readingOrder.Count, chapterTitle, ebook.Id));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process EPUB for ebook {EbookId}.", ebookId);
|
||||
return Result.Fail(new Error($"Failed to process EPUB: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> ExtractParagraphs(string html)
|
||||
/// <summary>
|
||||
/// Attempts to resolve a web-relative storage path to an absolute filesystem path.
|
||||
/// Searches upward from the app base directory to handle both dev and production layouts.
|
||||
/// </summary>
|
||||
private static string? ResolvePath(string relativePath)
|
||||
{
|
||||
// Normalize forward-slashes to OS separator for file system access
|
||||
var normalized = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||
|
||||
var currentDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
|
||||
while (currentDir != null)
|
||||
{
|
||||
var candidate = Path.Combine(currentDir.FullName, "wwwroot", normalized);
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
|
||||
// Also try src/NexusReader.Web/wwwroot (development layout)
|
||||
var devCandidate = Path.Combine(currentDir.FullName, "src", "NexusReader.Web", "wwwroot", normalized);
|
||||
if (File.Exists(devCandidate)) return devCandidate;
|
||||
|
||||
currentDir = currentDir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string> ExtractParagraphs(string html)
|
||||
{
|
||||
var bodyMatch = Regex.Match(html, @"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
|
||||
|
||||
var paragraphs = new List<string>();
|
||||
// Match block-level elements: h1-h6, p, ul, ol, blockquote, pre
|
||||
// We match the whole tag to preserve it for sanitization
|
||||
var matches = Regex.Matches(content, @"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
paragraphs.Add(match.Value);
|
||||
}
|
||||
|
||||
// Fallback: split by double newlines if no block tags found
|
||||
if (paragraphs.Count == 0)
|
||||
{
|
||||
paragraphs = content.Split(new[] { "<br />", "<br>", "\n\n", "\r\n\r\n" }, StringSplitOptions.RemoveEmptyEntries).ToList();
|
||||
@@ -163,76 +163,43 @@ public class EpubReaderService : IEpubReader
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
private string SanitizeParagraph(string html)
|
||||
private static 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 allowed structural and formatting tags
|
||||
clean = Regex.Replace(clean, @"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b)[^>]+>", "", RegexOptions.IgnoreCase);
|
||||
|
||||
// 3. Requirement: Aggressively strip attributes (class, style, id) from allowed tags
|
||||
clean = Regex.Replace(clean, @"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", "<$1>", RegexOptions.IgnoreCase);
|
||||
|
||||
// 4. Decode HTML entities
|
||||
clean = System.Net.WebUtility.HtmlDecode(clean);
|
||||
|
||||
return clean.Trim();
|
||||
}
|
||||
|
||||
private int CountWords(string text)
|
||||
private static 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,
|
||||
private static AiActionTriggerBlock CreateAiTrigger(string id) =>
|
||||
new(id,
|
||||
"Wykryto ciekawy fragment! Czy chcesz, abym wygenerował podsumowanie lub quiz z tego rozdziału?",
|
||||
new List<string> { "Podsumuj", "Generuj Quiz", "Pomiń" }
|
||||
);
|
||||
}
|
||||
new List<string> { "Podsumuj", "Generuj Quiz", "Pomiń" });
|
||||
|
||||
private string? FindTitleInNavigation(IEnumerable<EpubNavigationItemRef> navigation, string? filePath)
|
||||
private static string? FindTitleInNavigation(IEnumerable<EpubNavigationItemRef> navigation, string? filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath)) return null;
|
||||
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
|
||||
foreach (var item in navigation)
|
||||
{
|
||||
// Match by full path or just filename as fallback
|
||||
if (item.Link?.ContentFilePath == filePath || item.Link?.ContentFilePath == fileName)
|
||||
return item.Title;
|
||||
|
||||
if (item.NestedItems != null && item.NestedItems.Any())
|
||||
|
||||
if (item.NestedItems?.Any() == true)
|
||||
{
|
||||
var childTitle = FindTitleInNavigation(item.NestedItems, filePath);
|
||||
if (childTitle != null) return childTitle;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
// Metadata extraction moved to EpubMetadataExtractor
|
||||
}
|
||||
|
||||
public class EpubMetadataExtractor : IEpubMetadataExtractor
|
||||
{
|
||||
public async Task<Result<LocalEpubMetadata>> ExtractMetadataAsync(Stream epubStream)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var bookRef = await EpubReader.OpenBookAsync(epubStream);
|
||||
var title = bookRef.Title ?? "Unknown Title";
|
||||
var author = bookRef.Author ?? "Unknown Author";
|
||||
byte[]? cover = await bookRef.ReadCoverAsync();
|
||||
return Result.Ok(new LocalEpubMetadata { Title = title, Author = author, CoverImage = cover });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Failed to extract EPUB metadata locally: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.ML.Tokenizers;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
@@ -19,12 +20,15 @@ namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
public class KnowledgeService : IKnowledgeService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
private readonly IChatClient _chatClient;
|
||||
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
private readonly ResiliencePipeline _retryPipeline;
|
||||
private readonly AiSettings _settings;
|
||||
private readonly Tokenizer _tokenizer;
|
||||
private readonly ILogger<KnowledgeService> _logger;
|
||||
private const string PromptVersion = "1.0";
|
||||
|
||||
public KnowledgeService(
|
||||
@@ -32,14 +36,16 @@ public class KnowledgeService : IKnowledgeService
|
||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
ResiliencePipelineProvider<string> pipelineProvider,
|
||||
IOptions<AiSettings> settings)
|
||||
IOptions<AiSettings> settings,
|
||||
ILogger<KnowledgeService> logger)
|
||||
{
|
||||
_chatClient = chatClient;
|
||||
_embeddingGenerator = embeddingGenerator;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
||||
_settings = settings.Value;
|
||||
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
||||
_logger = logger;
|
||||
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
||||
// a very reliable estimation for token usage in Gemini-based workloads.
|
||||
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
|
||||
}
|
||||
@@ -78,16 +84,19 @@ public class KnowledgeService : IKnowledgeService
|
||||
|
||||
if (cached != null && cached.PromptVersion == PromptVersion)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] Cache Hit for {traceType} ({hash})");
|
||||
_logger.LogDebug("[KnowledgeService] Cache Hit for {TraceType} ({Hash})", traceType, hash);
|
||||
try
|
||||
{
|
||||
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, JsonOptions);
|
||||
if (packet != null) return Result.Ok(packet);
|
||||
}
|
||||
catch { /* fallback to regen */ }
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[KnowledgeService] Cached JSON for {Hash} was invalid; regenerating.", hash);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[KnowledgeService] Cache Miss for {traceType} ({hash}). Requesting AI...");
|
||||
_logger.LogInformation("[KnowledgeService] Cache Miss for {TraceType} ({Hash}). Requesting AI...", traceType, hash);
|
||||
try
|
||||
{
|
||||
var options = new ChatOptions
|
||||
@@ -112,7 +121,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
|
||||
try
|
||||
{
|
||||
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse, JsonOptions);
|
||||
if (knowledgePacket == null) return Result.Fail("Failed to deserialize AI response.");
|
||||
|
||||
// 3. Generate Embedding if not present
|
||||
@@ -125,7 +134,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] Embedding Error: {ex.Message}");
|
||||
_logger.LogWarning(ex, "[KnowledgeService] Embedding generation failed; proceeding without vector.");
|
||||
// We continue even if embedding fails, as the primary goal was knowledge extraction
|
||||
}
|
||||
|
||||
@@ -159,7 +168,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] JSON Error: {ex.Message}. Raw length: {rawResponse.Length}");
|
||||
_logger.LogError(ex, "[KnowledgeService] JSON deserialization error. Raw response length: {Length}", rawResponse.Length);
|
||||
return Result.Fail($"Failed to deserialize AI response: {ex.Message}");
|
||||
}
|
||||
}
|
||||
@@ -231,7 +240,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] WARNING: Skipping invalid link {linkDto.Source} -> {linkDto.Target} (Missing units).");
|
||||
_logger.LogWarning("[KnowledgeService] Skipping invalid link {Source} -> {Target}: one or both units are missing.", linkDto.Source, linkDto.Target);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,7 +273,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
var rawJson = response.Text?.Trim() ?? "{}";
|
||||
rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim();
|
||||
|
||||
var result = JsonSerializer.Deserialize<GroundednessResult>(rawJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
var result = JsonSerializer.Deserialize<GroundednessResult>(rawJson, JsonOptions);
|
||||
|
||||
return result != null ? Result.Ok(result) : Result.Fail("Failed to parse groundedness result");
|
||||
}
|
||||
|
||||
@@ -240,6 +240,8 @@
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// Clear the large byte array so it is eligible for GC even if the component is cached.
|
||||
_epubBytes = null;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@using NexusReader.Application.Queries.Reader
|
||||
@using Microsoft.JSInterop
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject IJSRuntime JS
|
||||
@@ -11,8 +12,8 @@
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
@inject IReaderInteractionService InteractionService
|
||||
@inject ISyncService SyncService
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject ILogger<ReaderCanvas> Logger
|
||||
|
||||
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
||||
@if (ViewModel == null)
|
||||
@@ -59,7 +60,7 @@
|
||||
await Coordinator.ClearAsync();
|
||||
ThemeService.OnThemeChanged += HandleUpdate;
|
||||
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
||||
|
||||
|
||||
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
|
||||
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
|
||||
InteractionService.OnTextSelected += HandleTextSelected;
|
||||
@@ -102,7 +103,10 @@
|
||||
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
|
||||
await module.InvokeVoidAsync("initSelectionListener", DotNetObjectReference.Create(this), _containerRef);
|
||||
}
|
||||
catch { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to initialize JS selection listener. Text selection will be unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeObserverAsync()
|
||||
@@ -112,24 +116,25 @@
|
||||
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/readerObserver.js");
|
||||
await module.InvokeVoidAsync("initObserver", DotNetObjectReference.Create(this), ".reader-flow-container", ".block-wrapper");
|
||||
}
|
||||
catch { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to initialize JS scroll observer. Reading progress sync will be unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task HandleBlockReached(string blockId, string content)
|
||||
{
|
||||
await Coordinator.OnBlockReachedAsync(blockId, content);
|
||||
|
||||
|
||||
if (ViewModel != null)
|
||||
{
|
||||
// Calculate progress: (CurrentChapter / TotalChapters) * 100
|
||||
// Simple approximation for now: chapter-based
|
||||
double progress = ((double)(ViewModel.CurrentChapterIndex + 1) / ViewModel.TotalChapters) * 100;
|
||||
|
||||
|
||||
await SyncService.UpdateProgressAsync(
|
||||
blockId,
|
||||
ViewModel.EbookId,
|
||||
progress,
|
||||
blockId,
|
||||
ViewModel.EbookId,
|
||||
progress,
|
||||
ViewModel.ChapterTitle,
|
||||
ViewModel.CurrentChapterIndex);
|
||||
}
|
||||
@@ -137,10 +142,8 @@
|
||||
|
||||
private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp)
|
||||
{
|
||||
// For now, let's just scroll to the node if it's in the current view,
|
||||
// or just log it. Usually, we should prompt the user.
|
||||
Console.WriteLine($"[Sync] Received progress from another device: {blockId} at {timestamp}");
|
||||
|
||||
Logger.LogInformation("[Sync] Received progress from another device: block {BlockId} at {Timestamp}", blockId, timestamp);
|
||||
|
||||
await ScrollToNodeAsync(blockId);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
@@ -148,7 +151,7 @@
|
||||
[JSInvokable]
|
||||
public async Task HandleTextSelected(string text, string blockId, SelectionCoordinates coords)
|
||||
{
|
||||
Console.WriteLine($"[ReaderCanvas] Text selected: {text} at {coords.Top},{coords.Left}");
|
||||
Logger.LogDebug("[ReaderCanvas] Text selected in block {BlockId}", blockId);
|
||||
_selectedText = text;
|
||||
_selectedBlockId = blockId;
|
||||
_selectionCoords = coords;
|
||||
@@ -172,7 +175,7 @@
|
||||
{
|
||||
_highlightedBlockId = blockId;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
await Task.Delay(3000); // Highlight for 3 seconds
|
||||
await Task.Delay(3000);
|
||||
if (_highlightedBlockId == blockId)
|
||||
{
|
||||
_highlightedBlockId = null;
|
||||
@@ -192,37 +195,42 @@
|
||||
{
|
||||
ViewModel = null;
|
||||
StatusMessage = "Fetching content...";
|
||||
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
var result = await Mediator.Send(new GetReaderPageQuery(index, userId));
|
||||
var ebookId = NavigationService.CurrentEbookId;
|
||||
if (ebookId == Guid.Empty)
|
||||
{
|
||||
StatusMessage = "No book selected. Please open a book from your library.";
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await Mediator.Send(new GetReaderPageQuery(ebookId, index, userId));
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
ViewModel = result.Value;
|
||||
await NavigationService.UpdateMetadataAsync(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle);
|
||||
|
||||
// Trigger full page graph generation after loading
|
||||
|
||||
await Coordinator.ProcessFullPageAsync(GetFullPageContent());
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "Failed to load"}";
|
||||
Logger.LogError("Failed to load chapter {Index} for ebook {EbookId}: {Errors}", index, ebookId, string.Join(", ", result.Errors.Select(e => e.Message)));
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleAiAction(string action)
|
||||
{
|
||||
Console.WriteLine($"Action Triggered from Bubble: {action}");
|
||||
}
|
||||
|
||||
public async Task ScrollToNodeAsync(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("eval", $"document.getElementById('{id}')?.scrollIntoView({{ behavior: 'smooth', block: 'center' }});");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to scroll to node {NodeId}.", id);
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||
@@ -231,7 +239,7 @@
|
||||
{
|
||||
ThemeService.OnThemeChanged -= HandleUpdate;
|
||||
NavigationService.OnNavigationChanged -= OnNavigationChanged;
|
||||
|
||||
|
||||
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
||||
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
||||
InteractionService.OnTextSelected -= HandleTextSelected;
|
||||
|
||||
@@ -2,19 +2,20 @@ namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface IReaderNavigationService
|
||||
{
|
||||
Guid CurrentEbookId { get; }
|
||||
int CurrentChapterIndex { get; }
|
||||
int TotalChapters { get; }
|
||||
string ChapterTitle { get; }
|
||||
|
||||
|
||||
event Func<Task>? OnNavigationChanged;
|
||||
|
||||
|
||||
Task GoToChapter(int index);
|
||||
Task GoToNextChapter();
|
||||
Task GoToPreviousChapter();
|
||||
Task UpdateMetadataAsync(int currentIndex, int totalChapters, string title);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to the reader for a specific book.
|
||||
/// Navigates to the reader for a specific book and records the current ebook ID.
|
||||
/// </summary>
|
||||
void NavigateToBook(Guid bookId);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
private readonly IReaderInteractionService _interactionService;
|
||||
private readonly ILogger<KnowledgeCoordinator> _logger;
|
||||
|
||||
public event Action<GraphDataDto>? OnGraphUpdated;
|
||||
/// <summary>
|
||||
/// Raised when the knowledge graph has been updated with new data.
|
||||
/// Subscribers must return a Task to enable proper async handling.
|
||||
/// </summary>
|
||||
public event Func<GraphDataDto, Task>? OnGraphUpdated;
|
||||
|
||||
public KnowledgeCoordinator(
|
||||
IKnowledgeService knowledgeService,
|
||||
@@ -61,7 +65,8 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
if (packet.Graph != null)
|
||||
{
|
||||
await _graphService.UpdateGraph(packet.Graph);
|
||||
OnGraphUpdated?.Invoke(packet.Graph);
|
||||
if (OnGraphUpdated != null)
|
||||
await OnGraphUpdated.Invoke(packet.Graph);
|
||||
await _platformService.VibrateSuccessAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
@@ -12,6 +11,7 @@ public class ReaderNavigationService : IReaderNavigationService
|
||||
_navigationManager = navigationManager;
|
||||
}
|
||||
|
||||
public Guid CurrentEbookId { get; private set; } = Guid.Empty;
|
||||
public int CurrentChapterIndex { get; private set; } = 0;
|
||||
public int TotalChapters { get; private set; } = 1;
|
||||
public string ChapterTitle { get; private set; } = "Loading...";
|
||||
@@ -21,7 +21,7 @@ public class ReaderNavigationService : IReaderNavigationService
|
||||
public async Task GoToChapter(int index)
|
||||
{
|
||||
if (index < 0 || index >= TotalChapters) return;
|
||||
|
||||
|
||||
CurrentChapterIndex = index;
|
||||
await NotifyNavigationChangedAsync();
|
||||
}
|
||||
@@ -48,7 +48,7 @@ public class ReaderNavigationService : IReaderNavigationService
|
||||
if (CurrentChapterIndex != currentIndex) { CurrentChapterIndex = currentIndex; changed = true; }
|
||||
if (TotalChapters != totalChapters) { TotalChapters = totalChapters; changed = true; }
|
||||
if (ChapterTitle != title) { ChapterTitle = title; changed = true; }
|
||||
|
||||
|
||||
if (changed)
|
||||
{
|
||||
await NotifyNavigationChangedAsync();
|
||||
@@ -57,6 +57,8 @@ public class ReaderNavigationService : IReaderNavigationService
|
||||
|
||||
public void NavigateToBook(Guid bookId)
|
||||
{
|
||||
CurrentEbookId = bookId;
|
||||
CurrentChapterIndex = 0;
|
||||
_navigationManager.NavigateTo($"/reader/{bookId}");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using FluentResults;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
@@ -10,6 +10,7 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INativeStorageService _storageService;
|
||||
private readonly IPlatformService _platformService;
|
||||
private readonly ILogger<SyncService> _logger;
|
||||
private HubConnection? _hubConnection;
|
||||
private bool _isInitialized;
|
||||
private CancellationTokenSource? _debounceCts;
|
||||
@@ -19,11 +20,13 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
public SyncService(
|
||||
HttpClient httpClient,
|
||||
INativeStorageService storageService,
|
||||
IPlatformService platformService)
|
||||
IPlatformService platformService,
|
||||
ILogger<SyncService> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_storageService = storageService;
|
||||
_platformService = platformService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Result> InitializeAsync()
|
||||
@@ -78,9 +81,9 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
try
|
||||
{
|
||||
await Task.Delay(2000, token);
|
||||
|
||||
|
||||
if (!_isInitialized) await InitializeAsync();
|
||||
|
||||
|
||||
if (_hubConnection?.State == HubConnectionState.Connected)
|
||||
{
|
||||
await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, token);
|
||||
@@ -90,7 +93,7 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
catch (TaskCanceledException) { /* Ignored, user kept scrolling */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SyncService] Error sending progress: {ex.Message}");
|
||||
_logger.LogError(ex, "[SyncService] Error sending reading progress for page {PageId}.", pageId);
|
||||
}
|
||||
}, token);
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ using NexusReader.Application;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using NexusReader.Data.Persistence;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
@@ -42,10 +45,12 @@ builder.Services.AddHttpClient("NexusAPI", client =>
|
||||
|
||||
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
|
||||
|
||||
// Dummy registrations for server-only handlers to satisfy DI validation
|
||||
// Dummy registrations for server-only handlers to satisfy DI validation in WASM
|
||||
builder.Services.AddSingleton<IDbContextFactory<AppDbContext>>(new ThrowingDbContextFactory());
|
||||
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator());
|
||||
builder.Services.AddSingleton<IBookStorageService>(new ThrowingBookStorageService());
|
||||
builder.Services.AddSingleton<IEbookRepository>(new ThrowingEbookRepository());
|
||||
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
|
||||
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddScoped<IEpubReader, WasmEpubReader>();
|
||||
@@ -75,3 +80,22 @@ public class ThrowingBookStorageService : IBookStorageService
|
||||
public Task<string?> SaveCoverAsync(byte[] data, string fileName) => throw new NotSupportedException(ErrorMessage);
|
||||
public Task<string?> SaveCoverAsync(Stream data, string fileName) => throw new NotSupportedException(ErrorMessage);
|
||||
}
|
||||
|
||||
public class ThrowingEbookRepository : IEbookRepository
|
||||
{
|
||||
private const string ErrorMessage = "Ebook repository operations are not supported in the WASM client. Use the API endpoint for data access.";
|
||||
|
||||
public Task<Author?> FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
|
||||
public void AddAuthor(Author author) => throw new NotSupportedException(ErrorMessage);
|
||||
public void AddEbook(Ebook ebook) => throw new NotSupportedException(ErrorMessage);
|
||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
|
||||
}
|
||||
|
||||
public class ThrowingSyncBroadcaster : ISyncBroadcaster
|
||||
{
|
||||
public Task BroadcastProgressAsync(string userId, string pageId, DateTime timestamp, string? excludedConnectionId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Real-time broadcasting can only be performed by the server.");
|
||||
|
||||
public Task BroadcastIngestionProgressAsync(string userId, string message, double progress, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Real-time broadcasting can only be performed by the server.");
|
||||
}
|
||||
|
||||
@@ -14,24 +14,26 @@ public class WasmEpubReader : IEpubReader
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
public async Task<Result<ReaderPageViewModel>> GetEpubContentAsync(int chapterIndex, string? userId = null)
|
||||
public async Task<Result<ReaderPageViewModel>> GetEpubContentAsync(
|
||||
Guid ebookId,
|
||||
int chapterIndex,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/api/epub/{chapterIndex}");
|
||||
var response = await _httpClient.GetAsync($"/api/epub/{ebookId}/{chapterIndex}", cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var viewModel = await response.Content.ReadFromJsonAsync<ReaderPageViewModel>();
|
||||
var viewModel = await response.Content.ReadFromJsonAsync<ReaderPageViewModel>(cancellationToken: cancellationToken);
|
||||
return viewModel != null ? Result.Ok(viewModel) : Result.Fail("Failed to deserialize response.");
|
||||
}
|
||||
|
||||
// Try to read the error message from the body
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return Result.Fail($"Server error ({response.StatusCode}): {errorBody}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fallback for network errors or parsing exceptions
|
||||
return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using System.Security.Claims;
|
||||
using NexusReader.Infrastructure.Services;
|
||||
using Stripe;
|
||||
using Microsoft.Extensions.AI;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
|
||||
|
||||
@@ -239,10 +242,10 @@ app.MapStaticAssets();
|
||||
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||
|
||||
// API endpoint for WASM client to fetch EPUB content
|
||||
app.MapGet("/api/epub/{index}", async (int index, IEpubReader epubService, ClaimsPrincipal user) =>
|
||||
app.MapGet("/api/epub/{ebookId:guid}/{index:int}", async (Guid ebookId, int index, IEpubReader epubService, ClaimsPrincipal user) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var result = await epubService.GetEpubContentAsync(index, userId);
|
||||
var result = await epubService.GetEpubContentAsync(ebookId, index, userId);
|
||||
|
||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user