115 lines
4.3 KiB
C#
115 lines
4.3 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using FluentResults;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using NexusReader.Application.Abstractions.Messaging;
|
|
using NexusReader.Application.Queries.Graph;
|
|
using NexusReader.Data.Persistence;
|
|
|
|
namespace NexusReader.Application.Queries.Concepts;
|
|
|
|
internal sealed class GetBookConceptsMapQueryHandler : IQueryHandler<GetBookConceptsMapQuery, BookConceptsMapResultDto>
|
|
{
|
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
|
|
|
public GetBookConceptsMapQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
|
{
|
|
_dbContextFactory = dbContextFactory;
|
|
}
|
|
|
|
public async Task<Result<BookConceptsMapResultDto>> Handle(GetBookConceptsMapQuery request, CancellationToken cancellationToken)
|
|
{
|
|
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
// 1. Fetch user to extract reading progress (LastReadPageId)
|
|
var user = await dbContext.Users
|
|
.Where(u => u.Id == request.UserId)
|
|
.Select(u => new { u.LastReadPageId })
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
|
|
if (user == null)
|
|
{
|
|
return Result.Fail<BookConceptsMapResultDto>("User not found.");
|
|
}
|
|
|
|
var lastReadBlockId = user.LastReadPageId ?? string.Empty;
|
|
|
|
// 2. Fetch all KnowledgeUnits associated with the ebook and user's tenant
|
|
var units = await dbContext.KnowledgeUnits
|
|
.Where(k => k.EbookId == request.BookId &&
|
|
(k.TenantId == request.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)))
|
|
.OrderBy(k => k.CreatedAt)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var nodes = new List<GraphNodeDto>();
|
|
|
|
foreach (var unit in units)
|
|
{
|
|
// Only process units representing sections or conceptual milestones (usually starting with "seg-")
|
|
if (string.IsNullOrEmpty(unit.Id) || !unit.Id.StartsWith("seg-"))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string label = unit.Id;
|
|
string group = "concept";
|
|
string summary = unit.Content;
|
|
var keyTerms = new List<string>();
|
|
|
|
if (!string.IsNullOrEmpty(unit.MetadataJson))
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(unit.MetadataJson);
|
|
if (doc.RootElement.TryGetProperty("label", out var labelProp))
|
|
label = labelProp.GetString() ?? label;
|
|
if (doc.RootElement.TryGetProperty("group", out var groupProp))
|
|
group = groupProp.GetString() ?? group;
|
|
if (doc.RootElement.TryGetProperty("summary", out var summaryProp))
|
|
summary = summaryProp.GetString() ?? summary;
|
|
if (doc.RootElement.TryGetProperty("key_terms", out var ktProp) && ktProp.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var term in ktProp.EnumerateArray())
|
|
{
|
|
if (term.GetString() is string s)
|
|
keyTerms.Add(s);
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Fallback to defaults
|
|
}
|
|
}
|
|
|
|
nodes.Add(new GraphNodeDto(
|
|
Id: unit.Id,
|
|
Label: label,
|
|
Group: group,
|
|
Description: unit.Content,
|
|
Type: unit.Type.ToString(),
|
|
Summary: summary,
|
|
KeyTerms: keyTerms
|
|
));
|
|
}
|
|
|
|
// Return sorted by the numeric value in the seg-ID to ensure topdown vertical alignment
|
|
var sortedNodes = nodes
|
|
.OrderBy(n => ParseSegmentNumber(n.Id))
|
|
.ToList();
|
|
|
|
return Result.Ok(new BookConceptsMapResultDto(sortedNodes, lastReadBlockId));
|
|
}
|
|
|
|
private static int ParseSegmentNumber(string? id)
|
|
{
|
|
if (string.IsNullOrEmpty(id)) return 0;
|
|
var digits = new string(id.Where(char.IsDigit).ToArray());
|
|
return int.TryParse(digits, out var val) ? val : 0;
|
|
}
|
|
}
|