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).
This commit is contained in:
James Wampler
2026-08-20 14:21:44 -07:00
parent e3d410da0b
commit 661f2917ea
22 changed files with 890 additions and 28 deletions
+61 -2
View File
@@ -1,5 +1,6 @@
using System.Threading.Channels;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
@@ -13,10 +14,34 @@ public class ImportService(
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));
@@ -24,7 +49,7 @@ public class ImportService(
logger.LogInformation("Inspecting import source {SourceRoot}", request.SourceRoot);
var root = ImportPaths.ResolveRoot(request.SourceRoot);
var root = ResolveSourceRoot(request.SourceRoot);
var ledger = ImportPaths.ReadLedger(root);
var total = ImportPaths.CountChapterFiles(root);
@@ -48,7 +73,7 @@ public class ImportService(
logger.LogInformation(
"Starting import for {SourceRoot}, forceRestart {ForceRestart}", request.SourceRoot, request.ForceRestart);
var root = ImportPaths.ResolveRoot(request.SourceRoot);
var root = ResolveSourceRoot(request.SourceRoot);
if (request.ForceRestart)
{
@@ -88,6 +113,40 @@ public class ImportService(
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));