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:
2026-05-13 18:24:24 +00:00
committed by Marek Jaisński
parent d5c2952bec
commit 5a2223a4c8
39 changed files with 6134 additions and 301 deletions
@@ -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));
}
}
}
@@ -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)");
@@ -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");
}
}
}
+6
View File
@@ -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));
}
}
}
@@ -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);
+25 -1
View File
@@ -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));
}
}
+5 -2
View File
@@ -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);