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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user