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:
James Wampler
2026-08-06 18:36:40 -07:00
parent 40f93e40a8
commit 189ebf3237
66 changed files with 3310 additions and 364 deletions
+110
View File
@@ -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;
}
}