Importer Phase 1A

This commit is contained in:
Nick Beckley 2026-06-01 20:47:20 +01:00
parent 264f63b105
commit 8dd4d4d5a3
11 changed files with 926 additions and 2 deletions

View File

@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Mvc;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
public sealed class ImportsController(IImportService imports) : Controller
{
public IActionResult Index() => View(new ImportIndexViewModel
{
JsonText = ImportIndexViewModel.SampleJson
});
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Preview(ImportIndexViewModel model)
{
model = await imports.PreviewAsync(model);
return View("Index", model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Import(ImportIndexViewModel model)
{
var result = await imports.ImportAsync(model);
if (result.Succeeded && result.ProjectID.HasValue)
{
TempData["ImportMessage"] = result.Message;
return RedirectToAction("Details", "Projects", new { id = result.ProjectID.Value });
}
model.Preview ??= (await imports.PreviewAsync(model)).Preview;
model.StatusMessage = result.Message;
return View("Index", model);
}
}

View File

@ -0,0 +1,197 @@
using System.Data;
using System.Text;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IImportRepository
{
Task<bool> ProjectTitleExistsAsync(string projectTitle);
Task<ImportResult> ImportAsync(PlotLineImportPackage package);
}
public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository
{
private const string ImportMarker = "Imported via JSON Import Phase 1A";
public async Task<bool> ProjectTitleExistsAsync(string projectTitle)
{
using var connection = connectionFactory.CreateConnection();
var count = await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(1)
FROM dbo.Projects
WHERE IsArchived = 0 AND ProjectName = @ProjectName;
""",
new { ProjectName = projectTitle });
return count > 0;
}
public async Task<ImportResult> ImportAsync(PlotLineImportPackage package)
{
using var connection = connectionFactory.CreateConnection();
connection.Open();
using var transaction = connection.BeginTransaction();
try
{
var lookupIds = await GetLookupIdsAsync(connection, transaction);
var projectId = await connection.QuerySingleAsync<int>(
"dbo.Project_Save",
new
{
ProjectID = 0,
ProjectName = Clean(package.Project.Title),
Description = CombineText(package.Project.Description, package.Project.Notes, ImportMarker)
},
transaction,
commandType: CommandType.StoredProcedure);
foreach (var book in package.Books.OrderBy(x => x.SeriesOrder))
{
var bookId = await connection.QuerySingleAsync<int>(
"dbo.Book_Save",
new
{
BookID = 0,
ProjectID = projectId,
BookTitle = Clean(FormatBookTitle(book)),
BookNumber = book.SeriesOrder,
Description = CombineText(
book.Description,
book.DependsOnPreviousBook ? "Depends on previous book." : null,
book.Notes,
ImportMarker)
},
transaction,
commandType: CommandType.StoredProcedure);
foreach (var chapter in book.Chapters.OrderBy(x => x.Order))
{
var chapterId = await connection.QuerySingleAsync<int>(
"dbo.Chapter_Save",
new
{
ChapterID = 0,
BookID = bookId,
chapter.ChapterNumber,
ChapterTitle = Clean(chapter.Title),
Summary = CombineText(chapter.Summary, chapter.Notes, ImportMarker),
RevisionStatusID = lookupIds.RevisionStatusID
},
transaction,
commandType: CommandType.StoredProcedure);
foreach (var scene in chapter.Scenes.OrderBy(x => x.Order))
{
await connection.QuerySingleAsync<int>(
"dbo.Scene_Save",
new
{
SceneID = 0,
ChapterID = chapterId,
scene.SceneNumber,
SceneTitle = Clean(scene.Title),
Summary = CombineText(
scene.Summary,
MetadataLine("POV", scene.PovCharacterName),
MetadataLine("Location", scene.LocationName),
scene.Notes,
ImportMarker),
TimeModeID = lookupIds.TimeModeID,
StartDateTime = (DateTime?)null,
EndDateTime = (DateTime?)null,
DurationAmount = (decimal?)null,
DurationUnitID = (int?)null,
RelativeTimeText = Truncate(scene.DateTimeText, 200),
TimeConfidenceID = lookupIds.TimeConfidenceID,
ScenePurposeNotes = CombineText(scene.PurposeText),
SceneOutcomeNotes = CombineText(scene.OutcomeText),
RevisionStatusID = lookupIds.RevisionStatusID
},
transaction,
commandType: CommandType.StoredProcedure);
}
}
}
transaction.Commit();
return new ImportResult
{
Succeeded = true,
ProjectID = projectId,
Message = "Import complete."
};
}
catch
{
transaction.Rollback();
throw;
}
}
private static async Task<ImportLookupIds> GetLookupIdsAsync(IDbConnection connection, IDbTransaction transaction)
{
var revisionStatusId = await connection.QuerySingleOrDefaultAsync<int?>(
"SELECT TOP (1) RevisionStatusID FROM dbo.RevisionStatuses WHERE StatusName = N'Planned' ORDER BY SortOrder;",
transaction: transaction);
var timeModeId = await connection.QuerySingleOrDefaultAsync<int?>(
"SELECT TOP (1) TimeModeID FROM dbo.TimeModes WHERE TimeModeName = N'Relative' ORDER BY SortOrder;",
transaction: transaction);
var timeConfidenceId = await connection.QuerySingleOrDefaultAsync<int?>(
"SELECT TOP (1) TimeConfidenceID FROM dbo.TimeConfidences WHERE TimeConfidenceName = N'Unknown' ORDER BY SortOrder;",
transaction: transaction);
if (!revisionStatusId.HasValue || !timeModeId.HasValue || !timeConfidenceId.HasValue)
{
throw new InvalidOperationException("Import lookup values are missing. Run the core database script before importing.");
}
return new ImportLookupIds(revisionStatusId.Value, timeModeId.Value, timeConfidenceId.Value);
}
private static string FormatBookTitle(ImportBookDto book)
=> string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}";
private static string Clean(string? value) => (value ?? string.Empty).Trim();
private static string? MetadataLine(string label, string? value)
=> string.IsNullOrWhiteSpace(value) ? null : $"{label}: {value.Trim()}";
private static string? Truncate(string? value, int maxLength)
{
var clean = Clean(value);
return clean.Length == 0 ? null : clean[..Math.Min(clean.Length, maxLength)];
}
private static string? CombineText(params string?[] values)
{
var parts = values
.Select(Clean)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (parts.Count == 0)
{
return null;
}
var builder = new StringBuilder();
foreach (var part in parts)
{
if (builder.Length > 0)
{
builder.AppendLine().AppendLine();
}
builder.Append(part);
}
return builder.ToString();
}
private sealed record ImportLookupIds(int RevisionStatusID, int TimeModeID, int TimeConfidenceID);
}

View File

@ -0,0 +1,80 @@
using System.Text.Json.Serialization;
namespace PlotLine.Models;
public sealed class PlotLineImportPackage
{
public string? PackageVersion { get; set; }
public string? Source { get; set; }
public ImportProjectDto Project { get; set; } = new();
public List<ImportBookDto> Books { get; set; } = [];
[JsonExtensionData]
public Dictionary<string, object>? ExtensionData { get; set; }
}
public sealed class ImportProjectDto
{
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public string? Notes { get; set; }
}
public sealed class ImportBookDto
{
public string Title { get; set; } = string.Empty;
public string? Subtitle { get; set; }
public int SeriesOrder { get; set; }
public bool DependsOnPreviousBook { get; set; }
public string? Description { get; set; }
public string? Notes { get; set; }
public List<ImportChapterDto> Chapters { get; set; } = [];
}
public sealed class ImportChapterDto
{
public decimal ChapterNumber { get; set; }
public string Title { get; set; } = string.Empty;
public int Order { get; set; }
public string? Summary { get; set; }
public string? Notes { get; set; }
public List<ImportSceneDto> Scenes { get; set; } = [];
}
public sealed class ImportSceneDto
{
public decimal SceneNumber { get; set; }
public string Title { get; set; } = string.Empty;
public int Order { get; set; }
public string? Summary { get; set; }
public string? PovCharacterName { get; set; }
public string? LocationName { get; set; }
public string? DateTimeText { get; set; }
public string? PurposeText { get; set; }
public string? OutcomeText { get; set; }
public string? Notes { get; set; }
}
public sealed class ImportValidationResult
{
public bool IsValid => Errors.Count == 0;
public List<string> Errors { get; } = [];
public List<string> Warnings { get; } = [];
}
public sealed class ImportPreview
{
public PlotLineImportPackage? Package { get; init; }
public ImportValidationResult Validation { get; init; } = new();
public bool HasDuplicateProjectTitle { get; init; }
public int BookCount => Package?.Books.Count ?? 0;
public int ChapterCount => Package?.Books.Sum(book => book.Chapters.Count) ?? 0;
public int SceneCount => Package?.Books.Sum(book => book.Chapters.Sum(chapter => chapter.Scenes.Count)) ?? 0;
}
public sealed class ImportResult
{
public bool Succeeded { get; init; }
public int? ProjectID { get; init; }
public string Message { get; init; } = string.Empty;
}

View File

@ -39,6 +39,7 @@ public class Program
builder.Services.AddScoped<IScenarioRepository, ScenarioRepository>();
builder.Services.AddScoped<IArchiveRepository, ArchiveRepository>();
builder.Services.AddScoped<IAcceptanceRepository, AcceptanceRepository>();
builder.Services.AddScoped<IImportRepository, ImportRepository>();
builder.Services.AddScoped<IProjectService, ProjectService>();
builder.Services.AddScoped<IBookService, BookService>();
builder.Services.AddScoped<IChapterService, ChapterService>();
@ -57,6 +58,7 @@ public class Program
builder.Services.AddScoped<IScenarioService, ScenarioService>();
builder.Services.AddScoped<IArchiveService, ArchiveService>();
builder.Services.AddScoped<IAcceptanceService, AcceptanceService>();
builder.Services.AddScoped<IImportService, ImportService>();
var app = builder.Build();

View File

@ -0,0 +1,201 @@
using System.Text;
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IImportService
{
Task<ImportIndexViewModel> PreviewAsync(ImportIndexViewModel model);
Task<ImportResult> ImportAsync(ImportIndexViewModel model);
}
public sealed class ImportService(IImportRepository imports) : IImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public async Task<ImportIndexViewModel> PreviewAsync(ImportIndexViewModel model)
{
model.JsonText = await ReadJsonTextAsync(model);
model.Preview = await BuildPreviewAsync(model.JsonText);
return model;
}
public async Task<ImportResult> ImportAsync(ImportIndexViewModel model)
{
model.JsonText = await ReadJsonTextAsync(model);
var preview = await BuildPreviewAsync(model.JsonText);
model.Preview = preview;
if (!preview.Validation.IsValid || preview.Package is null)
{
return new ImportResult
{
Succeeded = false,
Message = "The JSON needs to pass validation before it can be imported."
};
}
if (preview.HasDuplicateProjectTitle && !model.AllowDuplicateProjectTitle)
{
return new ImportResult
{
Succeeded = false,
Message = "A project with this title already exists. Confirm that you want to import another project with the same title."
};
}
try
{
return await imports.ImportAsync(preview.Package);
}
catch (Exception ex)
{
return new ImportResult
{
Succeeded = false,
Message = $"The import could not be completed, so no data was saved. {ex.Message}"
};
}
}
private async Task<ImportPreview> BuildPreviewAsync(string jsonText)
{
if (string.IsNullOrWhiteSpace(jsonText))
{
var emptyValidation = new ImportValidationResult();
emptyValidation.Errors.Add("Paste JSON or upload a JSON file before previewing.");
return new ImportPreview { Validation = emptyValidation };
}
PlotLineImportPackage? package;
try
{
package = JsonSerializer.Deserialize<PlotLineImportPackage>(jsonText, JsonOptions);
}
catch (JsonException ex)
{
var invalidJson = new ImportValidationResult();
invalidJson.Errors.Add($"The JSON could not be read: {ex.Message}");
return new ImportPreview { Validation = invalidJson };
}
if (package is null)
{
var invalidPackage = new ImportValidationResult();
invalidPackage.Errors.Add("The JSON import package is empty.");
return new ImportPreview { Validation = invalidPackage };
}
var validation = Validate(package);
AddMetadataWarnings(package, validation);
var duplicate = !string.IsNullOrWhiteSpace(package.Project.Title)
&& await imports.ProjectTitleExistsAsync(package.Project.Title.Trim());
if (duplicate)
{
validation.Warnings.Add("A project with the same title already exists. Import is blocked until you confirm that duplicate title.");
}
return new ImportPreview
{
Package = package,
Validation = validation,
HasDuplicateProjectTitle = duplicate
};
}
private static ImportValidationResult Validate(PlotLineImportPackage package)
{
var result = new ImportValidationResult();
if (string.IsNullOrWhiteSpace(package.Project.Title))
{
result.Errors.Add("Project title is required.");
}
if (package.Books.Count == 0)
{
result.Warnings.Add("No books were supplied. The import will create an empty project.");
}
foreach (var group in package.Books.GroupBy(x => x.SeriesOrder).Where(x => x.Count() > 1))
{
result.Errors.Add($"Book order {group.Key} is used more than once.");
}
for (var bookIndex = 0; bookIndex < package.Books.Count; bookIndex++)
{
var book = package.Books[bookIndex];
var bookLabel = string.IsNullOrWhiteSpace(book.Title) ? $"Book {bookIndex + 1}" : book.Title;
if (string.IsNullOrWhiteSpace(book.Title))
{
result.Errors.Add($"Book {bookIndex + 1} is missing a title.");
}
foreach (var group in book.Chapters.GroupBy(x => x.Order).Where(x => x.Count() > 1))
{
result.Errors.Add($"{bookLabel} has duplicate chapter order {group.Key}.");
}
for (var chapterIndex = 0; chapterIndex < book.Chapters.Count; chapterIndex++)
{
var chapter = book.Chapters[chapterIndex];
var chapterLabel = string.IsNullOrWhiteSpace(chapter.Title) ? $"chapter {chapterIndex + 1}" : chapter.Title;
if (string.IsNullOrWhiteSpace(chapter.Title))
{
result.Errors.Add($"{bookLabel} chapter {chapterIndex + 1} is missing a title.");
}
foreach (var group in chapter.Scenes.GroupBy(x => x.Order).Where(x => x.Count() > 1))
{
result.Errors.Add($"{bookLabel} / {chapterLabel} has duplicate scene order {group.Key}.");
}
for (var sceneIndex = 0; sceneIndex < chapter.Scenes.Count; sceneIndex++)
{
if (string.IsNullOrWhiteSpace(chapter.Scenes[sceneIndex].Title))
{
result.Errors.Add($"{bookLabel} / {chapterLabel} scene {sceneIndex + 1} is missing a title.");
}
}
}
}
return result;
}
private static void AddMetadataWarnings(PlotLineImportPackage package, ImportValidationResult validation)
{
var hasPov = package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName))));
var hasLocation = package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName))));
if (hasPov)
{
validation.Warnings.Add("POV character names will be kept as imported text notes in scene summaries. Character records are not created in Phase 1A.");
}
if (hasLocation)
{
validation.Warnings.Add("Location names will be kept as imported text notes in scene summaries. Location records are not created in Phase 1A.");
}
}
private static async Task<string> ReadJsonTextAsync(ImportIndexViewModel model)
{
if (model.JsonFile is { Length: > 0 })
{
using var reader = new StreamReader(model.JsonFile.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
return await reader.ReadToEndAsync();
}
return model.JsonText ?? string.Empty;
}
}

View File

@ -0,0 +1,180 @@
using Microsoft.AspNetCore.Http;
using PlotLine.Models;
namespace PlotLine.ViewModels;
public sealed class ImportIndexViewModel
{
public string JsonText { get; set; } = string.Empty;
public IFormFile? JsonFile { get; set; }
public bool AllowDuplicateProjectTitle { get; set; }
public ImportPreview? Preview { get; set; }
public string? StatusMessage { get; set; }
public bool CanImport => Preview is { Validation.IsValid: true } && (!Preview.HasDuplicateProjectTitle || AllowDuplicateProjectTitle);
public static string SampleJson => """
{
"packageVersion": "1.0",
"source": "JSON Import Phase 1A sample",
"project": {
"title": "The Glass Meridian",
"description": "A placeholder two-book fantasy outline.",
"notes": "Imported sample data for testing."
},
"books": [
{
"title": "Book One",
"subtitle": "The Map Beneath the City",
"seriesOrder": 1,
"dependsOnPreviousBook": false,
"description": "The opening movement of the story.",
"notes": "Keep this book focused on discovery.",
"chapters": [
{
"chapterNumber": 1,
"title": "A Door in the Rain",
"order": 1,
"summary": "The protagonist finds a hidden route under the old district.",
"notes": "Establish the central mystery.",
"scenes": [
{
"sceneNumber": 1,
"title": "The Locked Arcade",
"order": 1,
"summary": "A late delivery leads to an impossible doorway.",
"povCharacterName": "Mara",
"locationName": "Old Arcade",
"dateTimeText": "Rainy evening",
"purposeText": "Introduce the mystery",
"outcomeText": "Mara keeps the brass key",
"notes": "Quiet, tense opening."
},
{
"sceneNumber": 2,
"title": "Footsteps Below",
"order": 2,
"summary": "Mara hears someone moving beneath the street.",
"povCharacterName": "Mara",
"locationName": "Service stair",
"dateTimeText": "Same evening",
"purposeText": "Escalate the discovery",
"outcomeText": "She chooses to descend",
"notes": "End on a decision."
}
]
},
{
"chapterNumber": 2,
"title": "The Lantern Court",
"order": 2,
"summary": "The hidden route opens into a forgotten civic space.",
"notes": "Add wonder, but keep danger near.",
"scenes": [
{
"sceneNumber": 1,
"title": "A City Under Glass",
"order": 1,
"summary": "Mara sees the underground court for the first time.",
"povCharacterName": "Mara",
"locationName": "Lantern Court",
"dateTimeText": "Later that night",
"purposeText": "Reveal the hidden setting",
"outcomeText": "Mara realizes the map is incomplete",
"notes": "Use strong visual contrast."
},
{
"sceneNumber": 2,
"title": "The Watchman's Offer",
"order": 2,
"summary": "A stranger offers help at a cost.",
"povCharacterName": "Mara",
"locationName": "Lantern Court",
"dateTimeText": "Later that night",
"purposeText": "Introduce an uneasy ally",
"outcomeText": "Mara accepts a temporary guide",
"notes": "Trust should feel provisional."
}
]
}
]
},
{
"title": "Book Two",
"subtitle": "The Clockwork Shore",
"seriesOrder": 2,
"dependsOnPreviousBook": true,
"description": "The story widens beyond the city.",
"notes": "Seed consequences from book one.",
"chapters": [
{
"chapterNumber": 1,
"title": "Tide Tables",
"order": 1,
"summary": "The route points toward a mechanical coastline.",
"notes": "Reorient the cast after the first book.",
"scenes": [
{
"sceneNumber": 1,
"title": "Departure Bell",
"order": 1,
"summary": "The expedition leaves before dawn.",
"povCharacterName": "Ilen",
"locationName": "East Station",
"dateTimeText": "Early morning",
"purposeText": "Start the next journey",
"outcomeText": "The group leaves the city",
"notes": "Keep momentum high."
},
{
"sceneNumber": 2,
"title": "Salt in the Gears",
"order": 2,
"summary": "The first coastal machine fails in a revealing way.",
"povCharacterName": "Ilen",
"locationName": "Clockwork Shore",
"dateTimeText": "Midday",
"purposeText": "Show the new world's rules",
"outcomeText": "They find a damaged signal plate",
"notes": "Make the mechanism readable."
}
]
},
{
"chapterNumber": 2,
"title": "The Signal Plate",
"order": 2,
"summary": "The signal points to a rival expedition.",
"notes": "Bring in pressure from outside the group.",
"scenes": [
{
"sceneNumber": 1,
"title": "A Message in Brass",
"order": 1,
"summary": "The plate reveals a warning.",
"povCharacterName": "Mara",
"locationName": "Shore workshop",
"dateTimeText": "Afternoon",
"purposeText": "Translate the clue",
"outcomeText": "The warning names a missing captain",
"notes": "Make the clue actionable."
},
{
"sceneNumber": 2,
"title": "Smoke on the Causeway",
"order": 2,
"summary": "A rival camp appears across the water.",
"povCharacterName": "Mara",
"locationName": "Tidal causeway",
"dateTimeText": "Sunset",
"purposeText": "Introduce opposition",
"outcomeText": "The group prepares to cross",
"notes": "End with forward motion."
}
]
}
]
}
]
}
""";
}

View File

@ -0,0 +1,137 @@
@model ImportIndexViewModel
@{
ViewData["Title"] = "JSON Import";
var preview = Model.Preview;
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Tools</p>
<h1>JSON Import</h1>
<p class="lead-text">Paste or upload a Phase 1A package, preview it, then import a new project when validation passes.</p>
</div>
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Index">Back to projects</a>
</div>
@if (!string.IsNullOrWhiteSpace(Model.StatusMessage))
{
<div class="alert alert-warning">@Model.StatusMessage</div>
}
<section class="edit-panel import-panel">
<form asp-action="Preview" method="post" enctype="multipart/form-data">
<div class="row g-3">
<div class="col-12">
<label asp-for="JsonFile" class="form-label">Upload JSON file</label>
<input asp-for="JsonFile" class="form-control" type="file" accept=".json,application/json" />
<div class="form-text">Uploading a file replaces the pasted text for this preview.</div>
</div>
<div class="col-12">
<label asp-for="JsonText" class="form-label">JSON package</label>
<textarea asp-for="JsonText" class="form-control import-json-input" rows="22" spellcheck="false"></textarea>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Validate and preview</button>
<button class="btn btn-outline-secondary" type="button" data-copy-sample>Use sample JSON</button>
</div>
</form>
</section>
@if (preview is not null)
{
<section class="list-section import-preview-section mt-3">
<div class="import-preview-heading">
<div>
<p class="eyebrow">Preview</p>
<h2>@(preview.Package?.Project.Title ?? "Import package")</h2>
</div>
<div class="import-counts">
<span><strong>@preview.BookCount</strong> books</span>
<span><strong>@preview.ChapterCount</strong> chapters</span>
<span><strong>@preview.SceneCount</strong> scenes</span>
</div>
</div>
@if (preview.Validation.Errors.Any())
{
<div class="alert alert-warning">
<strong>Validation needs attention</strong>
<ul class="mb-0 mt-2">
@foreach (var error in preview.Validation.Errors)
{
<li>@error</li>
}
</ul>
</div>
}
@if (preview.Validation.Warnings.Any())
{
<div class="alert alert-info">
<strong>Import notes</strong>
<ul class="mb-0 mt-2">
@foreach (var warning in preview.Validation.Warnings)
{
<li>@warning</li>
}
</ul>
</div>
}
@if (preview.Package is not null)
{
<div class="import-tree">
@foreach (var book in preview.Package.Books.OrderBy(x => x.SeriesOrder))
{
<article class="import-tree-book">
<h3>Book @book.SeriesOrder: @(string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}")</h3>
@foreach (var chapter in book.Chapters.OrderBy(x => x.Order))
{
<div class="import-tree-chapter">
<strong>Chapter @chapter.ChapterNumber: @chapter.Title</strong>
<ul>
@foreach (var scene in chapter.Scenes.OrderBy(x => x.Order))
{
<li>Scene @scene.SceneNumber: @scene.Title</li>
}
</ul>
</div>
}
</article>
}
</div>
<form asp-action="Import" method="post" class="import-confirm-form mt-3">
<input asp-for="JsonText" type="hidden" />
@if (preview.HasDuplicateProjectTitle)
{
<div class="form-check import-duplicate-confirm">
<input asp-for="AllowDuplicateProjectTitle" class="form-check-input" />
<label asp-for="AllowDuplicateProjectTitle" class="form-check-label">
Import anyway and create another project with this same title.
</label>
</div>
}
<button class="btn btn-primary mt-3" type="submit" disabled="@(!Model.CanImport)">Import project</button>
</form>
}
</section>
}
<script type="application/json" id="importSampleJson">@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ImportIndexViewModel.SampleJson))</script>
@section Scripts {
<script>
(() => {
const button = document.querySelector("[data-copy-sample]");
const textarea = document.querySelector("#JsonText");
const sample = document.querySelector("#importSampleJson");
if (!button || !textarea || !sample) return;
button.addEventListener("click", () => {
textarea.value = JSON.parse(sample.textContent);
textarea.focus();
});
})();
</script>
}

View File

@ -8,7 +8,10 @@
<p class="eyebrow">Series planning</p>
<h1>Projects</h1>
</div>
<a class="btn btn-primary" asp-action="Create">New project</a>
<div class="button-row">
<a class="btn btn-outline-primary" asp-controller="Imports" asp-action="Index">Import JSON</a>
<a class="btn btn-primary" asp-action="Create">New project</a>
</div>
</div>
@if (!Model.Any())

View File

@ -24,6 +24,9 @@
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Projects" asp-action="Index">Projects</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Imports" asp-action="Index">Import</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Writer" asp-action="Index">Writer Workspace</a>
</li>
@ -45,6 +48,10 @@
{
<div class="alert alert-warning">@archiveError</div>
}
@if (TempData["ImportMessage"] is string importMessage)
{
<div class="alert alert-info">@importMessage</div>
}
@RenderBody()
</main>
</div>

View File

@ -105,7 +105,8 @@ a:hover {
.warning-card,
.warning-mini-card,
.scenario-chapter,
.drag-drop-panel {
.drag-drop-panel,
.import-tree-book {
background: var(--plotline-surface);
border-color: var(--plotline-border-soft);
border-radius: var(--plotline-radius);

View File

@ -169,6 +169,85 @@ td.text-end {
max-width: 980px;
}
.import-panel,
.import-preview-section {
max-width: 1180px;
}
.import-json-input {
font-family: Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.88rem;
line-height: 1.45;
min-height: 34rem;
}
.import-preview-heading,
.import-counts {
display: flex;
align-items: flex-start;
gap: 12px;
}
.import-preview-heading {
justify-content: space-between;
margin-bottom: 16px;
}
.import-counts {
flex-wrap: wrap;
}
.import-counts span {
display: inline-flex;
align-items: baseline;
gap: 5px;
border: 1px solid var(--plotline-line);
border-radius: 999px;
padding: 5px 11px;
background: var(--plotline-soft);
color: var(--plotline-muted);
font-weight: 700;
}
.import-counts strong {
color: var(--plotline-ink);
}
.import-tree {
display: grid;
gap: 12px;
}
.import-tree-book {
border: 1px solid var(--plotline-line);
border-radius: 8px;
padding: 14px;
background: rgba(255, 255, 255, 0.54);
}
.import-tree-book h3 {
margin: 0 0 10px;
font-size: 1.08rem;
}
.import-tree-chapter {
border-top: 1px solid var(--plotline-line);
padding-top: 10px;
margin-top: 10px;
}
.import-tree-chapter ul {
margin: 8px 0 0;
color: var(--plotline-muted);
}
.import-duplicate-confirm {
border: 1px solid rgba(184, 118, 43, 0.34);
border-radius: 8px;
padding: 12px 12px 12px 38px;
background: rgba(251, 236, 211, 0.72);
}
.scene-editor {
max-width: 1180px;
}