From 1c6ee82d01aeb8baa6eabd71c6eba9aecac183f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Mon, 25 May 2026 08:51:21 +0200 Subject: [PATCH] feat: implement background ebook indexing with progress tracking and real-time UI updates --- .../Abstractions/Services/IEpubExtractor.cs | 17 ++ .../Library/IngestEbookCommandHandler.cs | 21 +- .../Commands/Library/ProcessEbookCommand.cs | 177 ++++++++++++++++ .../DTOs/User/UserProfileDto.cs | 1 + .../Queries/Graph/GraphViewModels.cs | 23 ++- .../Queries/Library/GetMyEbooksQuery.cs | 3 +- .../User/GetUserProfileQueryHandler.cs | 3 +- .../DependencyInjection.cs | 1 + .../Services/EpubExtractor.cs | 85 ++++++++ .../Services/KnowledgeService.cs | 2 +- .../Services/PromptRegistry.cs | 30 +-- .../Organisms/BookIngestionModal.razor | 88 +++++++- .../Organisms/BookIngestionModal.razor.css | 66 ++++++ .../Components/Organisms/ReaderCanvas.razor | 1 + .../Services/ISyncService.cs | 1 + .../Services/KnowledgeCoordinator.cs | 4 + .../Services/SyncService.cs | 6 + .../wwwroot/js/knowledgeGraph.js | 189 +++++++++++++++--- src/NexusReader.Web.Client/Program.cs | 7 + src/NexusReader.Web/Program.cs | 50 ++++- 20 files changed, 718 insertions(+), 57 deletions(-) create mode 100644 src/NexusReader.Application/Abstractions/Services/IEpubExtractor.cs create mode 100644 src/NexusReader.Application/Commands/Library/ProcessEbookCommand.cs create mode 100644 src/NexusReader.Infrastructure/Services/EpubExtractor.cs diff --git a/src/NexusReader.Application/Abstractions/Services/IEpubExtractor.cs b/src/NexusReader.Application/Abstractions/Services/IEpubExtractor.cs new file mode 100644 index 0000000..e5199d4 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IEpubExtractor.cs @@ -0,0 +1,17 @@ +using FluentResults; + +namespace NexusReader.Application.Abstractions.Services; + +/// +/// Service abstraction to extract raw text content from EPUB chapters. +/// +public interface IEpubExtractor +{ + /// + /// Extracts the sanitized, plain-text content of each chapter in the EPUB file. + /// + /// The relative storage path of the EPUB file. + /// Cancellation token. + /// A list of plain-text chapters, or a failure result. + Task>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default); +} diff --git a/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs index 0ae9e21..ca53adc 100644 --- a/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs +++ b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs @@ -1,5 +1,6 @@ using FluentResults; using MediatR; +using Microsoft.Extensions.DependencyInjection; using NexusReader.Application.Abstractions.Messaging; using NexusReader.Application.Abstractions.Persistence; using NexusReader.Application.Abstractions.Services; @@ -11,13 +12,16 @@ public class IngestEbookCommandHandler : IRequestHandler> Handle(IngestEbookCommand request, CancellationToken cancellationToken) @@ -72,6 +76,21 @@ public class IngestEbookCommandHandler : IRequestHandler + { + try + { + using var scope = _scopeFactory.CreateScope(); + var mediator = scope.ServiceProvider.GetRequiredService(); + await mediator.Send(new ProcessEbookCommand(ebook.Id, request.UserId, request.TenantId)); + } + catch (Exception) + { + // Swallowed to prevent ThreadPool crashes + } + }); + return Result.Ok(ebook.Id); } catch (Exception ex) diff --git a/src/NexusReader.Application/Commands/Library/ProcessEbookCommand.cs b/src/NexusReader.Application/Commands/Library/ProcessEbookCommand.cs new file mode 100644 index 0000000..5a1e9c4 --- /dev/null +++ b/src/NexusReader.Application/Commands/Library/ProcessEbookCommand.cs @@ -0,0 +1,177 @@ +using System.Text.RegularExpressions; +using FluentResults; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NexusReader.Application.Abstractions.Messaging; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Data.Persistence; + +namespace NexusReader.Application.Commands.Library; + +public record ProcessEbookCommand( + Guid EbookId, + string UserId, + string TenantId +) : ICommand; + +public class ProcessEbookCommandHandler : IRequestHandler> +{ + private readonly IDbContextFactory _dbContextFactory; + private readonly IKnowledgeService _knowledgeService; + private readonly IEpubExtractor _epubExtractor; + private readonly ISyncBroadcaster _broadcaster; + private readonly ILogger _logger; + + public ProcessEbookCommandHandler( + IDbContextFactory dbContextFactory, + IKnowledgeService knowledgeService, + IEpubExtractor epubExtractor, + ISyncBroadcaster broadcaster, + ILogger logger) + { + _dbContextFactory = dbContextFactory; + _knowledgeService = knowledgeService; + _epubExtractor = epubExtractor; + _broadcaster = broadcaster; + _logger = logger; + } + + public async Task> Handle(ProcessEbookCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("[ProcessEbook] Starting background processing for Ebook: {EbookId}", request.EbookId); + + try + { + await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Wyszukiwanie e-booka w bazie danych...", 0.05, cancellationToken); + + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + var ebook = await dbContext.Ebooks.FindAsync(new object[] { request.EbookId }, cancellationToken); + if (ebook == null) + { + _logger.LogError("[ProcessEbook] Ebook not found in database: {EbookId}", request.EbookId); + return Result.Fail($"Ebook nie znaleziony w bazie danych: {request.EbookId}"); + } + + _logger.LogInformation("[ProcessEbook] Extracting chapters text for Ebook: {Title} ({FilePath})", ebook.Title, ebook.FilePath); + await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Otwieranie i parsowanie pliku EPUB...", 0.1, cancellationToken); + + var extractionResult = await _epubExtractor.ExtractChaptersTextAsync(ebook.FilePath, cancellationToken); + if (extractionResult.IsFailed) + { + var errorMsg = extractionResult.Errors.FirstOrDefault()?.Message ?? "Failed to extract text chapters."; + _logger.LogError("[ProcessEbook] Extraction failed: {Error}", errorMsg); + return Result.Fail(extractionResult.Errors); + } + + var chapters = extractionResult.Value; + if (chapters == null || !chapters.Any()) + { + _logger.LogWarning("[ProcessEbook] EPUB has no readable content files: {EbookId}", request.EbookId); + return Result.Fail("EPUB nie zawiera czytelnych rozdziałów."); + } + + int totalChapters = chapters.Count; + _logger.LogInformation("[ProcessEbook] Processing {Count} chapters for Ebook: {Title}", totalChapters, ebook.Title); + + await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, $"Analizowanie struktury ({totalChapters} rozdziałów)...", 0.15, cancellationToken); + + int processedChapters = 0; + + for (int i = 0; i < totalChapters; i++) + { + var cleanText = chapters[i]; + + if (cleanText.Length < 100) + { + _logger.LogInformation("[ProcessEbook] Skipping chapter {Index} (text too short: {Length} chars)", i, cleanText.Length); + processedChapters++; + continue; + } + + // Chunk the text to maintain granular Knowledge Units + var chunks = ChunkText(cleanText, 3000); + _logger.LogInformation("[ProcessEbook] Chapter {Index} split into {ChunkCount} chunk(s)", i, chunks.Count); + + foreach (var chunk in chunks) + { + try + { + // Invoke GetKnowledgeMapAsync to extract, embed, and upsert knowledge units + var result = await _knowledgeService.GetKnowledgeMapAsync(chunk, request.TenantId, request.EbookId, cancellationToken); + if (result.IsFailed) + { + _logger.LogWarning("[ProcessEbook] Failed to generate knowledge map for a chunk of chapter {Index}: {Error}", i, result.Errors.FirstOrDefault()?.Message); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[ProcessEbook] Exception during AI vectorization of chapter {Index} chunk", i); + } + } + + processedChapters++; + double progress = 0.15 + (0.75 * processedChapters / totalChapters); + await _broadcaster.BroadcastIngestionProgressAsync( + request.UserId, + $"Przetwarzanie rozdziału {processedChapters} z {totalChapters} przez AI...", + progress, + cancellationToken); + } + + // Mark the ebook as ready + ebook.IsReadyForReading = true; + await dbContext.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title); + + await _broadcaster.BroadcastIngestionProgressAsync( + request.UserId, + "Indeksowanie wektorowe e-booka przez Nexus AI zakończone pomyślnie!", + 1.0, + cancellationToken); + + return Result.Ok(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "[ProcessEbook] Critical error during background EPUB vectorization of ebook {EbookId}", request.EbookId); + await _broadcaster.BroadcastIngestionProgressAsync( + request.UserId, + $"Błąd indeksowania: {ex.Message}", + 1.0, + cancellationToken); + return Result.Fail(new Error("Wystąpił błąd podczas indeksowania e-booka przez AI").CausedBy(ex)); + } + } + + private static List ChunkText(string text, int maxWords = 3000) + { + var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var chunks = new List(); + if (words.Length <= maxWords) + { + chunks.Add(text); + return chunks; + } + var currentChunk = new List(); + int count = 0; + foreach (var word in words) + { + currentChunk.Add(word); + count++; + if (count >= maxWords) + { + chunks.Add(string.Join(" ", currentChunk)); + currentChunk.Clear(); + count = 0; + } + } + if (currentChunk.Any()) + { + chunks.Add(string.Join(" ", currentChunk)); + } + return chunks; + } +} diff --git a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs index 27a0850..212a591 100644 --- a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs +++ b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs @@ -38,4 +38,5 @@ public record LastReadBookDto public string? LastChapter { get; init; } public int LastChapterIndex { get; init; } public string? Description { get; init; } + public bool IsReadyForReading { get; init; } } diff --git a/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs b/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs index 19d81e4..27b9ed6 100644 --- a/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs +++ b/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs @@ -1,9 +1,24 @@ +using System.Text.Json.Serialization; + namespace NexusReader.Application.Queries.Graph; -public record GraphNodeDto(string Id, string Label, string Group, string? Type = null); -public record GraphLinkDto(string Source, string Target, string RelationType, int Value = 1); +public record GraphNodeDto( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("label")] string Label, + [property: JsonPropertyName("group")] string Group, + [property: JsonPropertyName("description")] string? Description = null, + [property: JsonPropertyName("type")] string? Type = null +); + +public record GraphLinkDto( + [property: JsonPropertyName("source")] string Source, + [property: JsonPropertyName("target")] string Target, + [property: JsonPropertyName("type")] string RelationType, + [property: JsonPropertyName("value")] int Value = 1 +); + public record GraphDataDto { - public List Nodes { get; init; } = new(); - public List Links { get; init; } = new(); + [JsonPropertyName("nodes")] public List Nodes { get; init; } = new(); + [JsonPropertyName("links")] public List Links { get; init; } = new(); } diff --git a/src/NexusReader.Application/Queries/Library/GetMyEbooksQuery.cs b/src/NexusReader.Application/Queries/Library/GetMyEbooksQuery.cs index d3eef7e..712d8a9 100644 --- a/src/NexusReader.Application/Queries/Library/GetMyEbooksQuery.cs +++ b/src/NexusReader.Application/Queries/Library/GetMyEbooksQuery.cs @@ -37,7 +37,8 @@ public class GetMyEbooksQueryHandler : IRequestHandler ur.UserId == u.Id) diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 93ebd7f..6bc61ab 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -112,6 +112,7 @@ public static class DependencyInjection services.AddScoped(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution // that is environment-specific and incompatible with Singleton lifetime in MAUI. diff --git a/src/NexusReader.Infrastructure/Services/EpubExtractor.cs b/src/NexusReader.Infrastructure/Services/EpubExtractor.cs new file mode 100644 index 0000000..81f0d42 --- /dev/null +++ b/src/NexusReader.Infrastructure/Services/EpubExtractor.cs @@ -0,0 +1,85 @@ +using System.Text.RegularExpressions; +using FluentResults; +using Microsoft.Extensions.Logging; +using NexusReader.Application.Abstractions.Services; +using VersOne.Epub; + +namespace NexusReader.Infrastructure.Services; + +public class EpubExtractor : IEpubExtractor +{ + private readonly ILogger _logger; + + public EpubExtractor(ILogger logger) + { + _logger = logger; + } + + public async Task>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default) + { + try + { + var fullPath = ResolvePath(relativePath); + if (string.IsNullOrEmpty(fullPath) || !File.Exists(fullPath)) + { + _logger.LogError("[EpubExtractor] EPUB file not found at path: {FilePath}", relativePath); + return Result.Fail>($"Plik EPUB nie został znaleziony na dysku: {relativePath}"); + } + + using var bookRef = await EpubReader.OpenBookAsync(fullPath); + var readingOrder = bookRef.GetReadingOrder(); + + if (readingOrder == null || !readingOrder.Any()) + { + return Result.Fail>("EPUB nie zawiera czytelnych rozdziałów."); + } + + var chapters = new List(); + foreach (var chapterRef in readingOrder) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + var rawContent = await chapterRef.ReadContentAsTextAsync(); + var cleanText = StripHtml(rawContent); + chapters.Add(cleanText); + } + + return Result.Ok(chapters); + } + catch (Exception ex) + { + _logger.LogError(ex, "[EpubExtractor] Error extracting chapters from EPUB: {FilePath}", relativePath); + return Result.Fail>(new Error("Failed to parse and extract text from EPUB").CausedBy(ex)); + } + } + + private static string? ResolvePath(string relativePath) + { + 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; + + var devCandidate = Path.Combine(currentDir.FullName, "src", "NexusReader.Web", "wwwroot", normalized); + if (File.Exists(devCandidate)) return devCandidate; + + currentDir = currentDir.Parent; + } + return null; + } + + private static string StripHtml(string html) + { + if (string.IsNullOrEmpty(html)) return string.Empty; + var clean = Regex.Replace(html, @"<(style|script)\b[^>]*>.*?", "", RegexOptions.IgnoreCase | RegexOptions.Singleline); + clean = Regex.Replace(clean, @"<[^>]*>", " "); + clean = System.Net.WebUtility.HtmlDecode(clean); + clean = Regex.Replace(clean, @"\s+", " ").Trim(); + return clean; + } +} diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index 5868f7b..4f84f5c 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -33,7 +33,7 @@ public class KnowledgeService : IKnowledgeService private readonly ILogger _logger; private readonly QdrantClient _qdrantClient; private readonly IDriver _neo4jDriver; - private const string PromptVersion = "1.3"; + private const string PromptVersion = "1.6"; private static readonly ConcurrentDictionary>>> _activeRequests = new(); public KnowledgeService( diff --git a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs index f456e61..bb64433 100644 --- a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs +++ b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs @@ -4,9 +4,10 @@ public static class PromptRegistry { public const string KnowledgeExtractionSystemPrompt = "You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " + - "CRITICAL: Restrict 'concept.label' to a maximum of 3 words (e.g., 'Dependency Injection' instead of full sentences). " + + "**LANGUAGE CRITICAL**: Detect the language of the provided text. You MUST generate all human-readable fields ('title', 'description', 'question', 'options', 'label') in the EXACT SAME LANGUAGE as the source text. Do NOT translate them to English unless the source text is in English. " + + "CRITICAL: Restrict 'concept.label' to a maximum of 3 words (e.g., 'Dependency Injection' or its exact foreign equivalent, never full sentences). " + "CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points from the text. " + - "CRITICAL: Code blocks (e.g., markdown code snippets) must be excluded from the relationship graph, or summarized as a single node (e.g., 'Code Example'). Do NOT create nodes for variables, functions, namespaces, or individual lines of code. " + + "CRITICAL: Code blocks (e.g., markdown code snippets) must be excluded from the relationship graph, or summarized as a single node with the label 'Code Example' translated to the detected language. Do NOT create nodes for variables, functions, namespaces, or individual lines of code. " + "CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " + "Schema: { " + "\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " + @@ -15,26 +16,31 @@ public static class PromptRegistry "}."; public const string GraphExtractionPrompt = - "You are an expert at information architecture. Extract key concepts and paragraph mappings from the text to build a unified knowledge graph. " + + "You are an expert at information architecture. Extract a highly strategic, clean, and educational knowledge graph from the provided technical text to act as a clear structural roadmap, avoiding clutter or hyper-connected noise hubs. " + + "**LANGUAGE CRITICAL**: Detect the language of the provided text. The 'label' and 'description' fields MUST be generated in the EXACT SAME LANGUAGE as the source text. Do NOT translate them to English. " + "The input text consists of several paragraphs, each starting with its unique block ID in the format '[ID: seg-X]'. " + - "Extract two types of nodes: " + - "1. Concept Nodes (group: 'concept'): Extract the main technical concepts discussed (e.g., ID: 'dependency-injection', label: 'Dependency Injection'). Max 10 concepts. Labels must be at most 3 words. " + - "2. Block Nodes (group: 'current'): For each paragraph in the input, create a node representing that paragraph where 'id' is the exact block ID (e.g., 'seg-1'), and 'label' is a brief summary of that paragraph's content (max 3 words). " + - "CRITICAL: If a paragraph is a code block, represent it as a single block node with label 'Code Example' (group: 'current'). Do NOT extract low-level code elements (like variables, classes, methods, or namespaces) as separate concept nodes. " + - "CRITICAL: Connect related concept nodes together, and connect each concept node to the block nodes ('seg-X') where it is discussed. " + - "Limit connections to a MAXIMUM of 15 most relevant links. " + - "Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }"; - + "Extract three distinct types of nodes based on strict hierarchical validation: " + + "1. Concept Nodes (group: 'concept'): Extract major global architectural pillars discussed. Max 6 per segment. Labels must be 1-3 words max. " + + "2. Bridge Nodes (group: 'bridge'): If the text directly compares a legacy paradigm (e.g., Desktop/WPF) to a modern framework alternative (.NET 10/Blazor), extract them as paired concepts to visually bridge the structural evolution. " + + "3. Block Nodes (group: 'current'): Create a node ONLY for significant structural landmarks in the text (e.g., major headings). Do NOT connect every concept to every individual paragraph wrapper. Connect concepts only to the main section block where they are anchored. " + + "CRITICAL NOISE SUPPRESSION: Absolutely forbid creating separate nodes for individual configuration files, files names, simple classes, servers, or methods (e.g., 'appsettings.json', 'Kestrel', 'Thread.Sleep', 'OnInitializedAsync'). These low-level details MUST be collapsed and described only within the 'description' field of their parent concept node. " + + "CRITICAL: Code blocks must be completely ignored as separate nodes; represent them only as contextual attributes within descriptions. " + + "Limit topology connections to a MAXIMUM of 10 highly relevant links total per segment. " + + "System keys configuration: 'group' must be strictly 'concept', 'bridge', 'current', 'rule', 'definition', 'table', or 'section'. " + + "Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|bridge|current\", \"description\": \"string\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"type\": \"maps_to|contains|relates_to\" } ] } }"; public const string SummaryAndQuizPrompt = "You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " + + "**LANGUAGE CRITICAL**: Detect the language of the provided text. The generated 'summary', 'question', and 'options' MUST be in the EXACT SAME LANGUAGE as the source text. " + "Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }"; public const string KM_ExtractionPrompt = "You are an expert at Knowledge Engineering. Segment the provided text into discrete Knowledge Units. " + + "**LANGUAGE CRITICAL**: Detect the language of the provided text. The 'content' field MUST be in the EXACT SAME LANGUAGE as the source text. " + "Identify 'units' (sections, tables, definitions, rules) and 'links' (how they relate). " + "CRITICAL: Units must be granular. " + - "CRITICAL: Code blocks must be summarized under the parent unit or represented as a single 'Code Example' unit. Do NOT segment code blocks into granular low-level code details (e.g., classes, variables, parameters). " + + "CRITICAL: Code blocks must be summarized under the parent unit or represented as a single 'Code Example' unit (translate the name to the detected language). Do NOT segment code blocks into granular low-level code details (e.g., classes, variables, parameters). " + + "CRITICAL SYSTEM VALUES: The fields 'type' (strictly: 'Section', 'Table', 'Definition', or 'Rule') and 'relation' (strictly: 'Next', 'Defines', 'Contains', or 'References') are system keys and MUST remain in English as specified. " + "Schema: { " + "\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " + "\"links\": [ { \"source\": \"string\", \"target\": \"string\", \"relation\": \"Next|Defines|Contains|References\" } ] " + diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor index 950da9a..4af21f7 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor @@ -2,12 +2,14 @@ @using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Queries.Reader @using NexusReader.Application.Commands.Library +@using NexusReader.UI.Shared.Services @using System.Net.Http.Json @inject IEpubMetadataExtractor MetadataExtractor @inject ILogger Logger @inject HttpClient Http @inject IReaderNavigationService ReaderNavigation @inject IJSRuntime JSRuntime +@inject ISyncService SyncService @implements IAsyncDisposable @if (IsOpen) @@ -16,20 +18,23 @@