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
@@ -1,4 +1,5 @@
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using Novelly.Api.Imports;
using Novelly.Api.Novels;
@@ -20,6 +21,8 @@ public class ImportServiceTests : ServiceTestFixture
Novels,
_queue,
UserContext,
Options.Create(new ImportOptions()),
new ImportZipExtractor(new CapturingLogger<ImportZipExtractor>()),
new CapturingLogger<ImportService>(),
new InspectImportRequestValidator(),
new StartImportRequestValidator());
@@ -32,6 +35,12 @@ public class ImportServiceTests : ServiceTestFixture
{
Directory.Delete(_root, recursive: true);
}
var staging = Path.Combine(Path.GetTempPath(), "novelly-import-staging");
if (Directory.Exists(staging))
{
Directory.Delete(staging, recursive: true);
}
}
[Test]
@@ -131,6 +140,71 @@ public class ImportServiceTests : ServiceTestFixture
});
}
[Test]
public async Task Starting_an_import_outside_a_configured_import_root_is_rejected()
{
var configuredRoot = Directory.CreateTempSubdirectory("novelly-import-root-").FullName;
try
{
var restricted = new ImportService(
Db.Context,
Novels,
_queue,
UserContext,
Options.Create(new ImportOptions { RootPath = configuredRoot }),
new ImportZipExtractor(new CapturingLogger<ImportZipExtractor>()),
new CapturingLogger<ImportService>(),
new InspectImportRequestValidator(),
new StartImportRequestValidator());
Assert.That(
async () => await restricted.StartOrResumeAsync(new StartImportRequest(_root)),
Throws.TypeOf<ArgumentException>());
}
finally
{
Directory.Delete(configuredRoot, recursive: true);
}
}
[Test]
public async Task Starting_an_import_of_a_single_markdown_file_stages_it_into_a_folder()
{
var file = Path.Combine(_root, "01-chapter.md");
File.WriteAllText(file, "# Chapter 1");
var job = await _imports.StartOrResumeAsync(new StartImportRequest(file));
Assert.Multiple(() =>
{
Assert.That(Directory.Exists(job.SourceRoot), Is.True);
Assert.That(File.Exists(Path.Combine(job.SourceRoot, "01-chapter.md")), Is.True);
});
}
[Test]
public async Task Starting_an_import_of_the_same_single_file_twice_dedupes_to_the_same_staged_job()
{
var file = Path.Combine(_root, "01-chapter.md");
File.WriteAllText(file, "# Chapter 1");
var first = await _imports.StartOrResumeAsync(new StartImportRequest(file));
var second = await _imports.StartOrResumeAsync(new StartImportRequest(file));
Assert.That(second.Id, Is.EqualTo(first.Id));
}
[Test]
public async Task Starting_an_import_of_a_non_markdown_file_is_rejected()
{
var file = Path.Combine(_root, "notes.txt");
File.WriteAllText(file, "not markdown");
Assert.That(
async () => await _imports.StartOrResumeAsync(new StartImportRequest(file)),
Throws.TypeOf<ArgumentException>());
}
private void WriteChapterFiles(int count)
{
var outlines = Path.Combine(_root, "outlines");