From 29d3da9d4eb07264b116fbaf07057759625462dc Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 1 Jun 2026 20:56:53 +0100 Subject: [PATCH] Importer Phase 1B --- PlotLine/Controllers/ImportsController.cs | 16 +- PlotLine/Data/ImportRepository.cs | 50 +++++- PlotLine/Models/ImportModels.cs | 22 +++ PlotLine/Services/ImportServices.cs | 188 +++++++++++++++++++++- PlotLine/ViewModels/ImportViewModels.cs | 10 +- PlotLine/Views/Imports/Index.cshtml | 122 +++++++++++--- PlotLine/Views/Imports/Result.cshtml | 39 +++++ PlotLine/wwwroot/css/plotline-theme.css | 4 +- PlotLine/wwwroot/css/site.css | 54 ++++++- 9 files changed, 461 insertions(+), 44 deletions(-) create mode 100644 PlotLine/Views/Imports/Result.cshtml diff --git a/PlotLine/Controllers/ImportsController.cs b/PlotLine/Controllers/ImportsController.cs index 58d1037..906c49b 100644 --- a/PlotLine/Controllers/ImportsController.cs +++ b/PlotLine/Controllers/ImportsController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using PlotLine.Services; using PlotLine.ViewModels; +using System.Text; namespace PlotLine.Controllers; @@ -11,6 +12,9 @@ public sealed class ImportsController(IImportService imports) : Controller JsonText = ImportIndexViewModel.SampleJson }); + public IActionResult Sample() + => File(Encoding.UTF8.GetBytes(ImportIndexViewModel.SampleJson), "application/json", "plotline-import-sample.json"); + [HttpPost] [ValidateAntiForgeryToken] public async Task Preview(ImportIndexViewModel model) @@ -26,12 +30,20 @@ public sealed class ImportsController(IImportService imports) : Controller 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 }); + return View("Result", result); } model.Preview ??= (await imports.PreviewAsync(model)).Preview; model.StatusMessage = result.Message; + model.TechnicalDetail = result.TechnicalDetail; return View("Index", model); } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ValidationReport(ImportIndexViewModel model) + { + var report = await imports.BuildValidationReportAsync(model); + return File(Encoding.UTF8.GetBytes(report), "text/plain", "plotline-import-validation-report.txt"); + } } diff --git a/PlotLine/Data/ImportRepository.cs b/PlotLine/Data/ImportRepository.cs index de3ef03..23fd488 100644 --- a/PlotLine/Data/ImportRepository.cs +++ b/PlotLine/Data/ImportRepository.cs @@ -8,12 +8,12 @@ namespace PlotLine.Data; public interface IImportRepository { Task ProjectTitleExistsAsync(string projectTitle); - Task ImportAsync(PlotLineImportPackage package); + Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle); } public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository { - private const string ImportMarker = "Imported via JSON Import Phase 1A"; + private const string ImportMarker = "Imported via JSON Import Phase 1B"; public async Task ProjectTitleExistsAsync(string projectTitle) { @@ -29,7 +29,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : return count > 0; } - public async Task ImportAsync(PlotLineImportPackage package) + public async Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle) { using var connection = connectionFactory.CreateConnection(); connection.Open(); @@ -37,14 +37,24 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : try { + var booksCreated = 0; + var chaptersCreated = 0; + var scenesCreated = 0; + var skippedFields = GetSkippedOptionalFields(package); + var projectName = string.IsNullOrWhiteSpace(importedProjectTitle) + ? Clean(package.Project.Title) + : Clean(importedProjectTitle); var lookupIds = await GetLookupIdsAsync(connection, transaction); var projectId = await connection.QuerySingleAsync( "dbo.Project_Save", new { ProjectID = 0, - ProjectName = Clean(package.Project.Title), - Description = CombineText(package.Project.Description, package.Project.Notes, ImportMarker) + ProjectName = projectName, + Description = CombineText( + package.Project.Description, + package.Project.Notes, + ImportMetadata(package)) }, transaction, commandType: CommandType.StoredProcedure); @@ -67,6 +77,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : }, transaction, commandType: CommandType.StoredProcedure); + booksCreated++; foreach (var chapter in book.Chapters.OrderBy(x => x.Order)) { @@ -83,6 +94,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : }, transaction, commandType: CommandType.StoredProcedure); + chaptersCreated++; foreach (var scene in chapter.Scenes.OrderBy(x => x.Order)) { @@ -113,6 +125,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : }, transaction, commandType: CommandType.StoredProcedure); + scenesCreated++; } } } @@ -122,6 +135,11 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : { Succeeded = true, ProjectID = projectId, + ProjectName = projectName, + BooksCreated = booksCreated, + ChaptersCreated = chaptersCreated, + ScenesCreated = scenesCreated, + SkippedOptionalFields = skippedFields, Message = "Import complete." }; } @@ -155,6 +173,28 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : private static string FormatBookTitle(ImportBookDto book) => string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}"; + private static string ImportMetadata(PlotLineImportPackage package) + { + var source = string.IsNullOrWhiteSpace(package.Source) ? null : $"Source: {package.Source.Trim()}"; + return CombineText(ImportMarker, $"Imported at {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC", source) ?? ImportMarker; + } + + private static IReadOnlyList GetSkippedOptionalFields(PlotLineImportPackage package) + { + var skipped = new List(); + if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName))))) + { + skipped.Add("POV character names were stored as plain text in scene summaries; Character records were not created."); + } + + if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName))))) + { + skipped.Add("Location names were stored as plain text in scene summaries; Location records were not created."); + } + + return skipped; + } + private static string Clean(string? value) => (value ?? string.Empty).Trim(); private static string? MetadataLine(string label, string? value) diff --git a/PlotLine/Models/ImportModels.cs b/PlotLine/Models/ImportModels.cs index 13863b0..1b34ac1 100644 --- a/PlotLine/Models/ImportModels.cs +++ b/PlotLine/Models/ImportModels.cs @@ -67,14 +67,36 @@ public sealed class ImportPreview public PlotLineImportPackage? Package { get; init; } public ImportValidationResult Validation { get; init; } = new(); public bool HasDuplicateProjectTitle { get; init; } + public string? DuplicateImportProjectTitle { get; init; } + public IReadOnlyList BookPreviews { 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 ImportBookPreview +{ + public ImportBookDto Book { get; init; } = new(); + public int ChapterCount => Book.Chapters.Count; + public int SceneCount => Book.Chapters.Sum(chapter => chapter.Scenes.Count); + public IReadOnlyList ChapterPreviews { get; init; } = []; +} + +public sealed class ImportChapterPreview +{ + public ImportChapterDto Chapter { get; init; } = new(); + public int SceneCount => Chapter.Scenes.Count; +} + public sealed class ImportResult { public bool Succeeded { get; init; } public int? ProjectID { get; init; } + public string ProjectName { get; init; } = string.Empty; + public int BooksCreated { get; init; } + public int ChaptersCreated { get; init; } + public int ScenesCreated { get; init; } + public IReadOnlyList SkippedOptionalFields { get; init; } = []; + public string? TechnicalDetail { get; init; } public string Message { get; init; } = string.Empty; } diff --git a/PlotLine/Services/ImportServices.cs b/PlotLine/Services/ImportServices.cs index 4e6d127..b8d921d 100644 --- a/PlotLine/Services/ImportServices.cs +++ b/PlotLine/Services/ImportServices.cs @@ -10,10 +10,18 @@ public interface IImportService { Task PreviewAsync(ImportIndexViewModel model); Task ImportAsync(ImportIndexViewModel model); + Task BuildValidationReportAsync(ImportIndexViewModel model); } -public sealed class ImportService(IImportRepository imports) : IImportService +public sealed class ImportService(IImportRepository imports, ILogger logger) : IImportService { + private const int TitleMaxLength = 200; + private const int RelativeTimeMaxLength = 200; + private const int LargeTextWarningLength = 100_000; + private const int SuspiciousBookCount = 20; + private const int SuspiciousChapterCount = 250; + private const int SuspiciousSceneCount = 2_000; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, @@ -43,6 +51,24 @@ public sealed class ImportService(IImportRepository imports) : IImportService }; } + if (!model.ConfirmImport) + { + return new ImportResult + { + Succeeded = false, + Message = "Confirm the import summary before creating records." + }; + } + + if (preview.Validation.Warnings.Any() && !model.AcknowledgeWarnings) + { + return new ImportResult + { + Succeeded = false, + Message = "Review and acknowledge the validation warnings before importing." + }; + } + if (preview.HasDuplicateProjectTitle && !model.AllowDuplicateProjectTitle) { return new ImportResult @@ -54,18 +80,38 @@ public sealed class ImportService(IImportRepository imports) : IImportService try { - return await imports.ImportAsync(preview.Package); + return await imports.ImportAsync(preview.Package, preview.HasDuplicateProjectTitle ? preview.DuplicateImportProjectTitle : null); } catch (Exception ex) { + logger.LogError(ex, "JSON import failed for project title {ProjectTitle}", preview.Package.Project.Title); return new ImportResult { Succeeded = false, - Message = $"The import could not be completed, so no data was saved. {ex.Message}" + Message = "The import could not be completed, so no data was saved.", + TechnicalDetail = ex.Message }; } } + public async Task BuildValidationReportAsync(ImportIndexViewModel model) + { + model.JsonText = await ReadJsonTextAsync(model); + var preview = await BuildPreviewAsync(model.JsonText); + var builder = new StringBuilder(); + builder.AppendLine("PlotLine JSON Import Validation Report"); + builder.AppendLine($"Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC"); + builder.AppendLine(); + builder.AppendLine($"Project: {preview.Package?.Project.Title ?? "(unreadable)"}"); + builder.AppendLine($"Books: {preview.BookCount}"); + builder.AppendLine($"Chapters: {preview.ChapterCount}"); + builder.AppendLine($"Scenes: {preview.SceneCount}"); + builder.AppendLine(); + AppendReportSection(builder, "Errors", preview.Validation.Errors); + AppendReportSection(builder, "Warnings", preview.Validation.Warnings); + return builder.ToString(); + } + private async Task BuildPreviewAsync(string jsonText) { if (string.IsNullOrWhiteSpace(jsonText)) @@ -99,16 +145,22 @@ public sealed class ImportService(IImportRepository imports) : IImportService var duplicate = !string.IsNullOrWhiteSpace(package.Project.Title) && await imports.ProjectTitleExistsAsync(package.Project.Title.Trim()); + var duplicateTitle = duplicate + ? $"{package.Project.Title.Trim()} (Imported {DateTime.Now:yyyy-MM-dd HH:mm})" + : null; + if (duplicate) { - validation.Warnings.Add("A project with the same title already exists. Import is blocked until you confirm that duplicate title."); + validation.Warnings.Add($"A project with the same title already exists. If imported anyway, the new project will be named \"{duplicateTitle}\"."); } return new ImportPreview { Package = package, Validation = validation, - HasDuplicateProjectTitle = duplicate + HasDuplicateProjectTitle = duplicate, + DuplicateImportProjectTitle = duplicateTitle, + BookPreviews = BuildBookPreviews(package) }; } @@ -120,12 +172,35 @@ public sealed class ImportService(IImportRepository imports) : IImportService { result.Errors.Add("Project title is required."); } + else if (package.Project.Title.Trim().Length > TitleMaxLength) + { + result.Errors.Add($"Project title is longer than {TitleMaxLength} characters."); + } + + ValidateLargeText(result, "Project description", package.Project.Description); + ValidateLargeText(result, "Project notes", package.Project.Notes); + + if (string.IsNullOrWhiteSpace(package.PackageVersion)) + { + result.Warnings.Add("packageVersion is missing. Version 1.0 is expected for Phase 1B."); + } + else if (package.PackageVersion.Trim() != "1.0") + { + result.Warnings.Add($"packageVersion \"{package.PackageVersion}\" is not the current supported version. The importer will continue if the structure is valid."); + } if (package.Books.Count == 0) { result.Warnings.Add("No books were supplied. The import will create an empty project."); } + var totalChapters = package.Books.Sum(book => book.Chapters.Count); + var totalScenes = package.Books.Sum(book => book.Chapters.Sum(chapter => chapter.Scenes.Count)); + if (package.Books.Count > SuspiciousBookCount || totalChapters > SuspiciousChapterCount || totalScenes > SuspiciousSceneCount) + { + result.Warnings.Add($"This is a large import ({package.Books.Count} books, {totalChapters} chapters, {totalScenes} scenes). Review the preview carefully before importing."); + } + 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."); @@ -139,6 +214,23 @@ public sealed class ImportService(IImportRepository imports) : IImportService { result.Errors.Add($"Book {bookIndex + 1} is missing a title."); } + else if (FormatBookTitle(book).Length > TitleMaxLength) + { + result.Errors.Add($"{bookLabel} title is longer than {TitleMaxLength} characters after combining title and subtitle."); + } + + if (book.SeriesOrder <= 0) + { + result.Errors.Add($"{bookLabel} is missing a valid seriesOrder."); + } + + if (book.Chapters.Count == 0) + { + result.Warnings.Add($"{bookLabel} has no chapters."); + } + + ValidateLargeText(result, $"{bookLabel} description", book.Description); + ValidateLargeText(result, $"{bookLabel} notes", book.Notes); foreach (var group in book.Chapters.GroupBy(x => x.Order).Where(x => x.Count() > 1)) { @@ -153,6 +245,23 @@ public sealed class ImportService(IImportRepository imports) : IImportService { result.Errors.Add($"{bookLabel} chapter {chapterIndex + 1} is missing a title."); } + else if (chapter.Title.Trim().Length > TitleMaxLength) + { + result.Errors.Add($"{bookLabel} / {chapterLabel} title is longer than {TitleMaxLength} characters."); + } + + if (chapter.Order <= 0) + { + result.Errors.Add($"{bookLabel} / {chapterLabel} is missing a valid chapter order."); + } + + if (chapter.Scenes.Count == 0) + { + result.Warnings.Add($"{bookLabel} / {chapterLabel} has no scenes."); + } + + ValidateLargeText(result, $"{bookLabel} / {chapterLabel} summary", chapter.Summary); + ValidateLargeText(result, $"{bookLabel} / {chapterLabel} notes", chapter.Notes); foreach (var group in chapter.Scenes.GroupBy(x => x.Order).Where(x => x.Count() > 1)) { @@ -161,10 +270,31 @@ public sealed class ImportService(IImportRepository imports) : IImportService for (var sceneIndex = 0; sceneIndex < chapter.Scenes.Count; sceneIndex++) { - if (string.IsNullOrWhiteSpace(chapter.Scenes[sceneIndex].Title)) + var scene = chapter.Scenes[sceneIndex]; + var sceneLabel = string.IsNullOrWhiteSpace(scene.Title) ? $"scene {sceneIndex + 1}" : scene.Title; + if (string.IsNullOrWhiteSpace(scene.Title)) { result.Errors.Add($"{bookLabel} / {chapterLabel} scene {sceneIndex + 1} is missing a title."); } + else if (scene.Title.Trim().Length > TitleMaxLength) + { + result.Errors.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} title is longer than {TitleMaxLength} characters."); + } + + if (scene.Order <= 0) + { + result.Errors.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} is missing a valid scene order."); + } + + if (!string.IsNullOrWhiteSpace(scene.DateTimeText) && scene.DateTimeText.Trim().Length > RelativeTimeMaxLength) + { + result.Warnings.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} dateTimeText is longer than {RelativeTimeMaxLength} characters and will be truncated."); + } + + ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} summary", scene.Summary); + ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} notes", scene.Notes); + ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} purposeText", scene.PurposeText); + ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} outcomeText", scene.OutcomeText); } } } @@ -179,15 +309,57 @@ public sealed class ImportService(IImportRepository imports) : IImportService 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."); + validation.Warnings.Add("POV character names will be kept as imported text notes in scene summaries. Character records are not created in Phase 1B."); } 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."); + validation.Warnings.Add("Location names will be kept as imported text notes in scene summaries. Location records are not created in Phase 1B."); } } + private static IReadOnlyList BuildBookPreviews(PlotLineImportPackage package) + => package.Books + .OrderBy(x => x.SeriesOrder) + .Select(book => new ImportBookPreview + { + Book = book, + ChapterPreviews = book.Chapters + .OrderBy(x => x.Order) + .Select(chapter => new ImportChapterPreview { Chapter = chapter }) + .ToList() + }) + .ToList(); + + private static void ValidateLargeText(ImportValidationResult result, string fieldName, string? value) + { + if (!string.IsNullOrWhiteSpace(value) && value.Length > LargeTextWarningLength) + { + result.Warnings.Add($"{fieldName} is unusually large. It can be imported, but review the source JSON for accidental pasted manuscript text."); + } + } + + private static string FormatBookTitle(ImportBookDto book) + => string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title.Trim() : $"{book.Title.Trim()}: {book.Subtitle.Trim()}"; + + private static void AppendReportSection(StringBuilder builder, string title, IReadOnlyList messages) + { + builder.AppendLine(title); + if (messages.Count == 0) + { + builder.AppendLine("- None"); + } + else + { + foreach (var message in messages) + { + builder.AppendLine($"- {message}"); + } + } + + builder.AppendLine(); + } + private static async Task ReadJsonTextAsync(ImportIndexViewModel model) { if (model.JsonFile is { Length: > 0 }) diff --git a/PlotLine/ViewModels/ImportViewModels.cs b/PlotLine/ViewModels/ImportViewModels.cs index 23db167..d8a3805 100644 --- a/PlotLine/ViewModels/ImportViewModels.cs +++ b/PlotLine/ViewModels/ImportViewModels.cs @@ -8,14 +8,20 @@ public sealed class ImportIndexViewModel public string JsonText { get; set; } = string.Empty; public IFormFile? JsonFile { get; set; } public bool AllowDuplicateProjectTitle { get; set; } + public bool ConfirmImport { get; set; } + public bool AcknowledgeWarnings { get; set; } public ImportPreview? Preview { get; set; } public string? StatusMessage { get; set; } - public bool CanImport => Preview is { Validation.IsValid: true } && (!Preview.HasDuplicateProjectTitle || AllowDuplicateProjectTitle); + public string? TechnicalDetail { get; set; } + public bool CanImport => Preview is { Validation.IsValid: true } + && (!Preview.Validation.Warnings.Any() || AcknowledgeWarnings) + && (!Preview.HasDuplicateProjectTitle || AllowDuplicateProjectTitle) + && ConfirmImport; public static string SampleJson => """ { "packageVersion": "1.0", - "source": "JSON Import Phase 1A sample", + "source": "JSON Import Phase 1B sample", "project": { "title": "The Glass Meridian", "description": "A placeholder two-book fantasy outline.", diff --git a/PlotLine/Views/Imports/Index.cshtml b/PlotLine/Views/Imports/Index.cshtml index a305f5b..57d9b7e 100644 --- a/PlotLine/Views/Imports/Index.cshtml +++ b/PlotLine/Views/Imports/Index.cshtml @@ -8,14 +8,31 @@

Tools

JSON Import

-

Paste or upload a Phase 1A package, preview it, then import a new project when validation passes.

+

Paste or upload a Phase 1B package, preview it, confirm the summary, then import a new project.

+
+ - Back to projects @if (!string.IsNullOrWhiteSpace(Model.StatusMessage)) { -
@Model.StatusMessage
+
+ Import paused +

@Model.StatusMessage

+ @if (preview is { Package: not null, HasDuplicateProjectTitle: true }) + { +

Duplicate imports will be renamed to @preview.DuplicateImportProjectTitle.

+ } + @if (!string.IsNullOrWhiteSpace(Model.TechnicalDetail)) + { +
+ Technical detail +

@Model.TechnicalDetail

+
+ } +
}
@@ -38,6 +55,26 @@
+
+
+ Supported JSON schema +
+
+

Required

+

project.title, each book.title, each book.seriesOrder, each chapter.title, each chapter.order, each scene.title, and each scene.order.

+
+
+

Optional

+

packageVersion, source, descriptions, notes, subtitles, chapter summaries, scene summaries, plain-text POV, plain-text location, date/time text, purpose text, and outcome text.

+
+
+

Not imported yet

+

No AI extraction, Word/text parsing, character records, location records, plot lines, assets, dependencies, relationships, merge/update, or export logic. Unknown JSON fields are ignored safely.

+
+
+
+
+ @if (preview is not null) {
@@ -53,10 +90,17 @@ +
+
+ + +
+
+ @if (preview.Validation.Errors.Any()) { -
- Validation needs attention +
+ Errors block import
    @foreach (var error in preview.Validation.Errors) { @@ -68,8 +112,8 @@ @if (preview.Validation.Warnings.Any()) { -
    - Import notes +
    + Warnings require confirmation
      @foreach (var warning in preview.Validation.Warnings) { @@ -82,39 +126,67 @@ @if (preview.Package is not null) {
      - @foreach (var book in preview.Package.Books.OrderBy(x => x.SeriesOrder)) + @foreach (var bookPreview in preview.BookPreviews) { -
      -

      Book @book.SeriesOrder: @(string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}")

      - @foreach (var chapter in book.Chapters.OrderBy(x => x.Order)) + var book = bookPreview.Book; +
      + + Book @book.SeriesOrder: @(string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}") + @bookPreview.ChapterCount chapters / @bookPreview.SceneCount scenes + + @foreach (var chapterPreview in bookPreview.ChapterPreviews) { -
      - Chapter @chapter.ChapterNumber: @chapter.Title + var chapter = chapterPreview.Chapter; +
      + + Chapter @chapter.ChapterNumber: @chapter.Title + @chapterPreview.SceneCount scenes +
        @foreach (var scene in chapter.Scenes.OrderBy(x => x.Order)) {
      • Scene @scene.SceneNumber: @scene.Title
      • }
      -
      +
      } -
      + }
      -
      - +
      +

      Confirm import

      +

      This will create @preview.Package.Project.Title with @preview.BookCount books, @preview.ChapterCount chapters, and @preview.SceneCount scenes.

      @if (preview.HasDuplicateProjectTitle) { -
      - - -
      +

      Because this title already exists, the created project will be named @preview.DuplicateImportProjectTitle if you continue.

      } - - + +
      + + @if (preview.Validation.Warnings.Any()) + { +
      + + +
      + } + @if (preview.HasDuplicateProjectTitle) + { +
      + + +
      + } +
      + + +
      + +
      +
      }
} diff --git a/PlotLine/Views/Imports/Result.cshtml b/PlotLine/Views/Imports/Result.cshtml new file mode 100644 index 0000000..e89fc65 --- /dev/null +++ b/PlotLine/Views/Imports/Result.cshtml @@ -0,0 +1,39 @@ +@model ImportResult +@{ + ViewData["Title"] = "Import Complete"; +} + +
+
+

Import result

+

Import complete

+

@Model.ProjectName was created successfully.

+
+ +
+ +
+
+ @Model.BooksCreated books created + @Model.ChaptersCreated chapters created + @Model.ScenesCreated scenes created +
+ + @if (Model.SkippedOptionalFields.Any()) + { +
+ Optional fields handled as plain text +
    + @foreach (var item in Model.SkippedOptionalFields) + { +
  • @item
  • + } +
+
+ } + +

The import was transactional, so all listed records were created together.

+
diff --git a/PlotLine/wwwroot/css/plotline-theme.css b/PlotLine/wwwroot/css/plotline-theme.css index a3a4fa0..eff614c 100644 --- a/PlotLine/wwwroot/css/plotline-theme.css +++ b/PlotLine/wwwroot/css/plotline-theme.css @@ -106,7 +106,9 @@ a:hover { .warning-mini-card, .scenario-chapter, .drag-drop-panel, -.import-tree-book { +.import-tree-book, +.import-confirmation, +.import-docs details { background: var(--plotline-surface); border-color: var(--plotline-border-soft); border-radius: var(--plotline-radius); diff --git a/PlotLine/wwwroot/css/site.css b/PlotLine/wwwroot/css/site.css index 0f4b8d2..bab0bd4 100644 --- a/PlotLine/wwwroot/css/site.css +++ b/PlotLine/wwwroot/css/site.css @@ -179,6 +179,10 @@ td.text-end { font-size: 0.88rem; line-height: 1.45; min-height: 34rem; + max-height: 64vh; + overflow: auto; + resize: vertical; + white-space: pre; } .import-preview-heading, @@ -225,6 +229,23 @@ td.text-end { background: rgba(255, 255, 255, 0.54); } +.import-tree-book > summary, +.import-tree-chapter > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + cursor: pointer; +} + +.import-tree-book > summary span, +.import-tree-chapter > summary span { + color: var(--plotline-muted); + font-size: 0.84rem; + font-weight: 700; + white-space: nowrap; +} + .import-tree-book h3 { margin: 0 0 10px; font-size: 1.08rem; @@ -232,7 +253,7 @@ td.text-end { .import-tree-chapter { border-top: 1px solid var(--plotline-line); - padding-top: 10px; + padding: 10px 0 0; margin-top: 10px; } @@ -248,6 +269,37 @@ td.text-end { background: rgba(251, 236, 211, 0.72); } +.import-confirmation, +.import-docs details { + border: 1px solid var(--plotline-line); + border-radius: 8px; + padding: 16px; + background: rgba(255, 255, 255, 0.42); +} + +.import-confirmation h3, +.import-doc-grid h3 { + margin-top: 0; + font-size: 1.05rem; +} + +.import-docs summary { + cursor: pointer; + font-weight: 800; +} + +.import-doc-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; + margin-top: 14px; +} + +.import-message-list ul { + max-height: 16rem; + overflow: auto; +} + .scene-editor { max-width: 1180px; }