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 queue, INovelUserContext userContext, IOptions importOptions, ImportZipExtractor zipExtractor, ILogger logger, IModelValidator inspectValidator, IModelValidator 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 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 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 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; } }