Files
novelly/src/Novelly.Api/Imports/ImportService.cs
T
James Wampler 661f2917ea Add zip upload and mapped-path picker for outline import
Sandbox source paths under a configured Imports:RootPath, browse it
from the web dialog, upload a zip that extracts into staging, and
import a single markdown file (agent infers chapter vs character).
2026-08-20 14:21:44 -07:00

160 lines
5.8 KiB
C#

using System.Threading.Channels;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Novels;
using Novelly.Api.Users;
namespace Novelly.Api.Imports;
public class ImportService(
INovelDbContext db,
NovelService novels,
Channel<Guid> queue,
INovelUserContext userContext,
IOptions<ImportOptions> importOptions,
ImportZipExtractor zipExtractor,
ILogger<ImportService> logger,
IModelValidator<InspectImportRequest> inspectValidator,
IModelValidator<StartImportRequest> startValidator)
{
private readonly string? _importRoot = importOptions.Value.RootPath is { } root ? Path.GetFullPath(root) : null;
public ImportUploadResponse UploadZip(Stream zipStream, string fileName)
{
if (_importRoot is null)
throw new InvalidOperationException("No import root is configured (Imports:RootPath).");
logger.LogInformation("Uploading import zip {FileName}", fileName);
Directory.CreateDirectory(_importRoot);
var stagingDir = Path.Combine(
ImportPaths.StagingRoot(_importRoot),
$"zip-{ImportPaths.SanitizeForFolderName(Path.GetFileNameWithoutExtension(fileName))}-{Guid.NewGuid():N}");
zipExtractor.Extract(zipStream, stagingDir);
var markdownCount = Directory.EnumerateFiles(stagingDir, "*.md", SearchOption.AllDirectories).Count();
var relativePath = Path.GetRelativePath(_importRoot, stagingDir).Replace(Path.DirectorySeparatorChar, '/');
return new ImportUploadResponse(stagingDir, relativePath, markdownCount);
}
public Task<ImportInspectionResponse> InspectAsync(InspectImportRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
inspectValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Inspecting import source {SourceRoot}", request.SourceRoot);
var root = ResolveSourceRoot(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.NovelId, chaptersDone, total, ledger.CompletedPasses ?? []));
}
public async Task<ImportJob> StartOrResumeAsync(StartImportRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
startValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation(
"Starting import for {SourceRoot}, forceRestart {ForceRestart}", request.SourceRoot, request.ForceRestart);
var root = ResolveSourceRoot(request.SourceRoot);
if (request.ForceRestart)
{
var ledger = ImportPaths.ReadLedger(root);
if (ledger?.NovelId is { } existingNovelId)
{
logger.LogWarning(
"Force-restarting import for {SourceRoot}: deleting novel {NovelId}", root, existingNovelId);
await novels.DeleteAsync(existingNovelId, 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),
RequestedByUserId = userContext.UserId
};
db.ImportJobs.Add(job);
await db.SaveChangesAsync(ct);
await queue.Writer.WriteAsync(job.Id, ct);
return job;
}
private string ResolveSourceRoot(string sourceRoot)
{
string full;
try
{
full = Path.GetFullPath(sourceRoot);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
throw new ArgumentException($"'{sourceRoot}' is not a valid path.", nameof(sourceRoot), ex);
}
if (!File.Exists(full))
{
return ImportPaths.ResolveRoot(sourceRoot, _importRoot);
}
if (!full.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException($"'{sourceRoot}' is not a markdown file or a directory.", nameof(sourceRoot));
ImportPaths.EnsureWithinImportRoot(_importRoot, full, sourceRoot);
var stagingParent = _importRoot is not null
? ImportPaths.StagingRoot(_importRoot)
: Path.Combine(Path.GetTempPath(), "novelly-import-staging");
var stagingDir = Path.Combine(
stagingParent, $"file-{ImportPaths.SanitizeForFolderName(Path.GetFileNameWithoutExtension(full))}");
Directory.CreateDirectory(stagingDir);
File.Copy(full, Path.Combine(stagingDir, Path.GetFileName(full)), overwrite: true);
return stagingDir;
}
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;
}
}