Add outline import feature; drop Dto naming, map entities at the API boundary
Services now return entities; endpoints (and the agent toolsets) map to *Response records instead of services building wire DTOs themselves. Also brings in the outline-import agent, MCP tool, ledger and web dialog that were already in progress on disk.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only inspection and job creation for outline imports. The actual import — reading
|
||||
/// source files, calling the model, writing project data — runs in <see cref="ImportAgentService"/>,
|
||||
/// driven off the request thread by <see cref="ImportJobRunner"/>; this service only ever
|
||||
/// touches the filesystem to peek at a ledger, never to import anything itself.
|
||||
/// </summary>
|
||||
public class ImportService(
|
||||
INovelDbContext db,
|
||||
ProjectService projects,
|
||||
Channel<Guid> queue,
|
||||
ILogger<ImportService> logger,
|
||||
IModelValidator<InspectImportRequest> inspectValidator,
|
||||
IModelValidator<StartImportRequest> startValidator)
|
||||
{
|
||||
/// <summary>
|
||||
/// Reports whether a folder is a fresh import, one to resume, or already complete —
|
||||
/// so the UI can offer the right action before committing to anything.
|
||||
/// </summary>
|
||||
public Task<ImportInspectionResponse> InspectAsync(InspectImportRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Null(request, nameof(request));
|
||||
inspectValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Inspecting import source {SourceRoot}", request.SourceRoot);
|
||||
|
||||
var root = ImportPaths.ResolveRoot(request.SourceRoot);
|
||||
var ledger = ImportPaths.ReadLedger(root);
|
||||
var total = ImportPaths.CountChapterFiles(root);
|
||||
|
||||
if (ledger is null)
|
||||
{
|
||||
return Task.FromResult(new ImportInspectionResponse(ImportReadiness.Fresh, null, 0, total, []));
|
||||
}
|
||||
|
||||
var chaptersDone = ledger.CompletedChapters?.Count ?? 0;
|
||||
var readiness = ImportPaths.IsComplete(ledger, total) ? ImportReadiness.Complete : ImportReadiness.Resumable;
|
||||
|
||||
return Task.FromResult(new ImportInspectionResponse(
|
||||
readiness, ledger.ProjectId, chaptersDone, total, ledger.CompletedPasses ?? []));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates (or reuses) an <see cref="ImportJob"/> for this source root and enqueues it
|
||||
/// for the background runner. <see cref="StartImportRequest.ForceRestart"/> deletes the
|
||||
/// ledger and the project it points at first — the "complete, delete and reimport" path —
|
||||
/// so make sure the caller has confirmed with the writer before setting it.
|
||||
/// </summary>
|
||||
public async Task<ImportJob> StartOrResumeAsync(StartImportRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Null(request, nameof(request));
|
||||
startValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation(
|
||||
"Starting import for {SourceRoot}, forceRestart {ForceRestart}", request.SourceRoot, request.ForceRestart);
|
||||
|
||||
var root = ImportPaths.ResolveRoot(request.SourceRoot);
|
||||
|
||||
if (request.ForceRestart)
|
||||
{
|
||||
var ledger = ImportPaths.ReadLedger(root);
|
||||
if (ledger?.ProjectId is { } existingProjectId)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Force-restarting import for {SourceRoot}: deleting project {ProjectId}", root, existingProjectId);
|
||||
await projects.DeleteAsync(existingProjectId, ct);
|
||||
}
|
||||
|
||||
ImportPaths.DeleteLedger(root);
|
||||
}
|
||||
|
||||
var existing = await db.ImportJobs
|
||||
.Where(j => j.SourceRoot == root
|
||||
&& (j.Status == ImportJobStatus.Pending || j.Status == ImportJobStatus.Running))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
logger.LogInformation("Import for {SourceRoot} is already {Status} as job {JobId}", root, existing.Status, existing.Id);
|
||||
return existing;
|
||||
}
|
||||
|
||||
var job = new ImportJob { SourceRoot = root, ChaptersTotal = ImportPaths.CountChapterFiles(root) };
|
||||
db.ImportJobs.Add(job);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await queue.Writer.WriteAsync(job.Id, ct);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>Null when no job has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<ImportJob?> GetStatusAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Getting import job {JobId}", id);
|
||||
|
||||
var job = await db.ImportJobs.FirstOrDefaultAsync(j => j.Id == id, ct);
|
||||
return job;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user