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).
160 lines
5.8 KiB
C#
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;
|
|
}
|
|
}
|