Importer Phase 1B
This commit is contained in:
parent
8dd4d4d5a3
commit
29d3da9d4e
@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using PlotLine.Services;
|
using PlotLine.Services;
|
||||||
using PlotLine.ViewModels;
|
using PlotLine.ViewModels;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace PlotLine.Controllers;
|
namespace PlotLine.Controllers;
|
||||||
|
|
||||||
@ -11,6 +12,9 @@ public sealed class ImportsController(IImportService imports) : Controller
|
|||||||
JsonText = ImportIndexViewModel.SampleJson
|
JsonText = ImportIndexViewModel.SampleJson
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public IActionResult Sample()
|
||||||
|
=> File(Encoding.UTF8.GetBytes(ImportIndexViewModel.SampleJson), "application/json", "plotline-import-sample.json");
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Preview(ImportIndexViewModel model)
|
public async Task<IActionResult> Preview(ImportIndexViewModel model)
|
||||||
@ -26,12 +30,20 @@ public sealed class ImportsController(IImportService imports) : Controller
|
|||||||
var result = await imports.ImportAsync(model);
|
var result = await imports.ImportAsync(model);
|
||||||
if (result.Succeeded && result.ProjectID.HasValue)
|
if (result.Succeeded && result.ProjectID.HasValue)
|
||||||
{
|
{
|
||||||
TempData["ImportMessage"] = result.Message;
|
return View("Result", result);
|
||||||
return RedirectToAction("Details", "Projects", new { id = result.ProjectID.Value });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model.Preview ??= (await imports.PreviewAsync(model)).Preview;
|
model.Preview ??= (await imports.PreviewAsync(model)).Preview;
|
||||||
model.StatusMessage = result.Message;
|
model.StatusMessage = result.Message;
|
||||||
|
model.TechnicalDetail = result.TechnicalDetail;
|
||||||
return View("Index", model);
|
return View("Index", model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> ValidationReport(ImportIndexViewModel model)
|
||||||
|
{
|
||||||
|
var report = await imports.BuildValidationReportAsync(model);
|
||||||
|
return File(Encoding.UTF8.GetBytes(report), "text/plain", "plotline-import-validation-report.txt");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,12 +8,12 @@ namespace PlotLine.Data;
|
|||||||
public interface IImportRepository
|
public interface IImportRepository
|
||||||
{
|
{
|
||||||
Task<bool> ProjectTitleExistsAsync(string projectTitle);
|
Task<bool> ProjectTitleExistsAsync(string projectTitle);
|
||||||
Task<ImportResult> ImportAsync(PlotLineImportPackage package);
|
Task<ImportResult> ImportAsync(PlotLineImportPackage package, string? importedProjectTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository
|
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<bool> ProjectTitleExistsAsync(string projectTitle)
|
public async Task<bool> ProjectTitleExistsAsync(string projectTitle)
|
||||||
{
|
{
|
||||||
@ -29,7 +29,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
return count > 0;
|
return count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ImportResult> ImportAsync(PlotLineImportPackage package)
|
public async Task<ImportResult> ImportAsync(PlotLineImportPackage package, string? importedProjectTitle)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
connection.Open();
|
connection.Open();
|
||||||
@ -37,14 +37,24 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
|
|
||||||
try
|
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 lookupIds = await GetLookupIdsAsync(connection, transaction);
|
||||||
var projectId = await connection.QuerySingleAsync<int>(
|
var projectId = await connection.QuerySingleAsync<int>(
|
||||||
"dbo.Project_Save",
|
"dbo.Project_Save",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
ProjectID = 0,
|
ProjectID = 0,
|
||||||
ProjectName = Clean(package.Project.Title),
|
ProjectName = projectName,
|
||||||
Description = CombineText(package.Project.Description, package.Project.Notes, ImportMarker)
|
Description = CombineText(
|
||||||
|
package.Project.Description,
|
||||||
|
package.Project.Notes,
|
||||||
|
ImportMetadata(package))
|
||||||
},
|
},
|
||||||
transaction,
|
transaction,
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
@ -67,6 +77,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
},
|
},
|
||||||
transaction,
|
transaction,
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
|
booksCreated++;
|
||||||
|
|
||||||
foreach (var chapter in book.Chapters.OrderBy(x => x.Order))
|
foreach (var chapter in book.Chapters.OrderBy(x => x.Order))
|
||||||
{
|
{
|
||||||
@ -83,6 +94,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
},
|
},
|
||||||
transaction,
|
transaction,
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
|
chaptersCreated++;
|
||||||
|
|
||||||
foreach (var scene in chapter.Scenes.OrderBy(x => x.Order))
|
foreach (var scene in chapter.Scenes.OrderBy(x => x.Order))
|
||||||
{
|
{
|
||||||
@ -113,6 +125,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
},
|
},
|
||||||
transaction,
|
transaction,
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
|
scenesCreated++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -122,6 +135,11 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
{
|
{
|
||||||
Succeeded = true,
|
Succeeded = true,
|
||||||
ProjectID = projectId,
|
ProjectID = projectId,
|
||||||
|
ProjectName = projectName,
|
||||||
|
BooksCreated = booksCreated,
|
||||||
|
ChaptersCreated = chaptersCreated,
|
||||||
|
ScenesCreated = scenesCreated,
|
||||||
|
SkippedOptionalFields = skippedFields,
|
||||||
Message = "Import complete."
|
Message = "Import complete."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -155,6 +173,28 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
private static string FormatBookTitle(ImportBookDto book)
|
private static string FormatBookTitle(ImportBookDto book)
|
||||||
=> string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}";
|
=> 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<string> GetSkippedOptionalFields(PlotLineImportPackage package)
|
||||||
|
{
|
||||||
|
var skipped = new List<string>();
|
||||||
|
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 Clean(string? value) => (value ?? string.Empty).Trim();
|
||||||
|
|
||||||
private static string? MetadataLine(string label, string? value)
|
private static string? MetadataLine(string label, string? value)
|
||||||
|
|||||||
@ -67,14 +67,36 @@ public sealed class ImportPreview
|
|||||||
public PlotLineImportPackage? Package { get; init; }
|
public PlotLineImportPackage? Package { get; init; }
|
||||||
public ImportValidationResult Validation { get; init; } = new();
|
public ImportValidationResult Validation { get; init; } = new();
|
||||||
public bool HasDuplicateProjectTitle { get; init; }
|
public bool HasDuplicateProjectTitle { get; init; }
|
||||||
|
public string? DuplicateImportProjectTitle { get; init; }
|
||||||
|
public IReadOnlyList<ImportBookPreview> BookPreviews { get; init; } = [];
|
||||||
public int BookCount => Package?.Books.Count ?? 0;
|
public int BookCount => Package?.Books.Count ?? 0;
|
||||||
public int ChapterCount => Package?.Books.Sum(book => book.Chapters.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 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<ImportChapterPreview> ChapterPreviews { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ImportChapterPreview
|
||||||
|
{
|
||||||
|
public ImportChapterDto Chapter { get; init; } = new();
|
||||||
|
public int SceneCount => Chapter.Scenes.Count;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ImportResult
|
public sealed class ImportResult
|
||||||
{
|
{
|
||||||
public bool Succeeded { get; init; }
|
public bool Succeeded { get; init; }
|
||||||
public int? ProjectID { 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<string> SkippedOptionalFields { get; init; } = [];
|
||||||
|
public string? TechnicalDetail { get; init; }
|
||||||
public string Message { get; init; } = string.Empty;
|
public string Message { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,10 +10,18 @@ public interface IImportService
|
|||||||
{
|
{
|
||||||
Task<ImportIndexViewModel> PreviewAsync(ImportIndexViewModel model);
|
Task<ImportIndexViewModel> PreviewAsync(ImportIndexViewModel model);
|
||||||
Task<ImportResult> ImportAsync(ImportIndexViewModel model);
|
Task<ImportResult> ImportAsync(ImportIndexViewModel model);
|
||||||
|
Task<string> BuildValidationReportAsync(ImportIndexViewModel model);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ImportService(IImportRepository imports) : IImportService
|
public sealed class ImportService(IImportRepository imports, ILogger<ImportService> 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()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true,
|
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)
|
if (preview.HasDuplicateProjectTitle && !model.AllowDuplicateProjectTitle)
|
||||||
{
|
{
|
||||||
return new ImportResult
|
return new ImportResult
|
||||||
@ -54,18 +80,38 @@ public sealed class ImportService(IImportRepository imports) : IImportService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await imports.ImportAsync(preview.Package);
|
return await imports.ImportAsync(preview.Package, preview.HasDuplicateProjectTitle ? preview.DuplicateImportProjectTitle : null);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
logger.LogError(ex, "JSON import failed for project title {ProjectTitle}", preview.Package.Project.Title);
|
||||||
return new ImportResult
|
return new ImportResult
|
||||||
{
|
{
|
||||||
Succeeded = false,
|
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<string> 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<ImportPreview> BuildPreviewAsync(string jsonText)
|
private async Task<ImportPreview> BuildPreviewAsync(string jsonText)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(jsonText))
|
if (string.IsNullOrWhiteSpace(jsonText))
|
||||||
@ -99,16 +145,22 @@ public sealed class ImportService(IImportRepository imports) : IImportService
|
|||||||
var duplicate = !string.IsNullOrWhiteSpace(package.Project.Title)
|
var duplicate = !string.IsNullOrWhiteSpace(package.Project.Title)
|
||||||
&& await imports.ProjectTitleExistsAsync(package.Project.Title.Trim());
|
&& 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)
|
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
|
return new ImportPreview
|
||||||
{
|
{
|
||||||
Package = package,
|
Package = package,
|
||||||
Validation = validation,
|
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.");
|
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)
|
if (package.Books.Count == 0)
|
||||||
{
|
{
|
||||||
result.Warnings.Add("No books were supplied. The import will create an empty project.");
|
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))
|
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.");
|
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.");
|
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))
|
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.");
|
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))
|
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++)
|
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.");
|
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)
|
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)
|
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<ImportBookPreview> 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<string> 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<string> ReadJsonTextAsync(ImportIndexViewModel model)
|
private static async Task<string> ReadJsonTextAsync(ImportIndexViewModel model)
|
||||||
{
|
{
|
||||||
if (model.JsonFile is { Length: > 0 })
|
if (model.JsonFile is { Length: > 0 })
|
||||||
|
|||||||
@ -8,14 +8,20 @@ public sealed class ImportIndexViewModel
|
|||||||
public string JsonText { get; set; } = string.Empty;
|
public string JsonText { get; set; } = string.Empty;
|
||||||
public IFormFile? JsonFile { get; set; }
|
public IFormFile? JsonFile { get; set; }
|
||||||
public bool AllowDuplicateProjectTitle { get; set; }
|
public bool AllowDuplicateProjectTitle { get; set; }
|
||||||
|
public bool ConfirmImport { get; set; }
|
||||||
|
public bool AcknowledgeWarnings { get; set; }
|
||||||
public ImportPreview? Preview { get; set; }
|
public ImportPreview? Preview { get; set; }
|
||||||
public string? StatusMessage { 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 => """
|
public static string SampleJson => """
|
||||||
{
|
{
|
||||||
"packageVersion": "1.0",
|
"packageVersion": "1.0",
|
||||||
"source": "JSON Import Phase 1A sample",
|
"source": "JSON Import Phase 1B sample",
|
||||||
"project": {
|
"project": {
|
||||||
"title": "The Glass Meridian",
|
"title": "The Glass Meridian",
|
||||||
"description": "A placeholder two-book fantasy outline.",
|
"description": "A placeholder two-book fantasy outline.",
|
||||||
|
|||||||
@ -8,14 +8,31 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Tools</p>
|
<p class="eyebrow">Tools</p>
|
||||||
<h1>JSON Import</h1>
|
<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>
|
<p class="lead-text">Paste or upload a Phase 1B package, preview it, confirm the summary, then import a new project.</p>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<a class="btn btn-outline-primary" asp-action="Sample">Download sample JSON</a>
|
||||||
|
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Index">Back to projects</a>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Index">Back to projects</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.StatusMessage))
|
@if (!string.IsNullOrWhiteSpace(Model.StatusMessage))
|
||||||
{
|
{
|
||||||
<div class="alert alert-warning">@Model.StatusMessage</div>
|
<div class="alert alert-warning">
|
||||||
|
<strong>Import paused</strong>
|
||||||
|
<p class="mb-0">@Model.StatusMessage</p>
|
||||||
|
@if (preview is { Package: not null, HasDuplicateProjectTitle: true })
|
||||||
|
{
|
||||||
|
<p class="mb-0 mt-2">Duplicate imports will be renamed to <strong>@preview.DuplicateImportProjectTitle</strong>.</p>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.TechnicalDetail))
|
||||||
|
{
|
||||||
|
<details class="mt-2">
|
||||||
|
<summary>Technical detail</summary>
|
||||||
|
<p class="mb-0 mt-2">@Model.TechnicalDetail</p>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="edit-panel import-panel">
|
<section class="edit-panel import-panel">
|
||||||
@ -38,6 +55,26 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="list-section import-docs mt-3">
|
||||||
|
<details>
|
||||||
|
<summary>Supported JSON schema</summary>
|
||||||
|
<div class="import-doc-grid">
|
||||||
|
<div>
|
||||||
|
<h3>Required</h3>
|
||||||
|
<p><code>project.title</code>, each <code>book.title</code>, each <code>book.seriesOrder</code>, each <code>chapter.title</code>, each <code>chapter.order</code>, each <code>scene.title</code>, and each <code>scene.order</code>.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Optional</h3>
|
||||||
|
<p><code>packageVersion</code>, <code>source</code>, descriptions, notes, subtitles, chapter summaries, scene summaries, plain-text POV, plain-text location, date/time text, purpose text, and outcome text.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Not imported yet</h3>
|
||||||
|
<p>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.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
@if (preview is not null)
|
@if (preview is not null)
|
||||||
{
|
{
|
||||||
<section class="list-section import-preview-section mt-3">
|
<section class="list-section import-preview-section mt-3">
|
||||||
@ -53,10 +90,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row mb-3">
|
||||||
|
<form asp-action="ValidationReport" method="post">
|
||||||
|
<input asp-for="JsonText" type="hidden" />
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="submit">Download validation report</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (preview.Validation.Errors.Any())
|
@if (preview.Validation.Errors.Any())
|
||||||
{
|
{
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning import-message-list">
|
||||||
<strong>Validation needs attention</strong>
|
<strong>Errors block import</strong>
|
||||||
<ul class="mb-0 mt-2">
|
<ul class="mb-0 mt-2">
|
||||||
@foreach (var error in preview.Validation.Errors)
|
@foreach (var error in preview.Validation.Errors)
|
||||||
{
|
{
|
||||||
@ -68,8 +112,8 @@
|
|||||||
|
|
||||||
@if (preview.Validation.Warnings.Any())
|
@if (preview.Validation.Warnings.Any())
|
||||||
{
|
{
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info import-message-list">
|
||||||
<strong>Import notes</strong>
|
<strong>Warnings require confirmation</strong>
|
||||||
<ul class="mb-0 mt-2">
|
<ul class="mb-0 mt-2">
|
||||||
@foreach (var warning in preview.Validation.Warnings)
|
@foreach (var warning in preview.Validation.Warnings)
|
||||||
{
|
{
|
||||||
@ -82,39 +126,67 @@
|
|||||||
@if (preview.Package is not null)
|
@if (preview.Package is not null)
|
||||||
{
|
{
|
||||||
<div class="import-tree">
|
<div class="import-tree">
|
||||||
@foreach (var book in preview.Package.Books.OrderBy(x => x.SeriesOrder))
|
@foreach (var bookPreview in preview.BookPreviews)
|
||||||
{
|
{
|
||||||
<article class="import-tree-book">
|
var book = bookPreview.Book;
|
||||||
<h3>Book @book.SeriesOrder: @(string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}")</h3>
|
<details class="import-tree-book" open>
|
||||||
@foreach (var chapter in book.Chapters.OrderBy(x => x.Order))
|
<summary>
|
||||||
|
<strong>Book @book.SeriesOrder: @(string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}")</strong>
|
||||||
|
<span>@bookPreview.ChapterCount chapters / @bookPreview.SceneCount scenes</span>
|
||||||
|
</summary>
|
||||||
|
@foreach (var chapterPreview in bookPreview.ChapterPreviews)
|
||||||
{
|
{
|
||||||
<div class="import-tree-chapter">
|
var chapter = chapterPreview.Chapter;
|
||||||
<strong>Chapter @chapter.ChapterNumber: @chapter.Title</strong>
|
<details class="import-tree-chapter">
|
||||||
|
<summary>
|
||||||
|
<strong>Chapter @chapter.ChapterNumber: @chapter.Title</strong>
|
||||||
|
<span>@chapterPreview.SceneCount scenes</span>
|
||||||
|
</summary>
|
||||||
<ul>
|
<ul>
|
||||||
@foreach (var scene in chapter.Scenes.OrderBy(x => x.Order))
|
@foreach (var scene in chapter.Scenes.OrderBy(x => x.Order))
|
||||||
{
|
{
|
||||||
<li>Scene @scene.SceneNumber: @scene.Title</li>
|
<li>Scene @scene.SceneNumber: @scene.Title</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</details>
|
||||||
}
|
}
|
||||||
</article>
|
</details>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form asp-action="Import" method="post" class="import-confirm-form mt-3">
|
<section class="import-confirmation mt-3">
|
||||||
<input asp-for="JsonText" type="hidden" />
|
<h3>Confirm import</h3>
|
||||||
|
<p>This will create <strong>@preview.Package.Project.Title</strong> with <strong>@preview.BookCount</strong> books, <strong>@preview.ChapterCount</strong> chapters, and <strong>@preview.SceneCount</strong> scenes.</p>
|
||||||
@if (preview.HasDuplicateProjectTitle)
|
@if (preview.HasDuplicateProjectTitle)
|
||||||
{
|
{
|
||||||
<div class="form-check import-duplicate-confirm">
|
<p class="muted">Because this title already exists, the created project will be named <strong>@preview.DuplicateImportProjectTitle</strong> if you continue.</p>
|
||||||
<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>
|
<form asp-action="Import" method="post" class="import-confirm-form">
|
||||||
|
<input asp-for="JsonText" type="hidden" />
|
||||||
|
@if (preview.Validation.Warnings.Any())
|
||||||
|
{
|
||||||
|
<div class="form-check import-duplicate-confirm">
|
||||||
|
<input asp-for="AcknowledgeWarnings" class="form-check-input" />
|
||||||
|
<label asp-for="AcknowledgeWarnings" class="form-check-label">I have reviewed the warnings and still want to import this package.</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (preview.HasDuplicateProjectTitle)
|
||||||
|
{
|
||||||
|
<div class="form-check import-duplicate-confirm mt-2">
|
||||||
|
<input asp-for="AllowDuplicateProjectTitle" class="form-check-input" />
|
||||||
|
<label asp-for="AllowDuplicateProjectTitle" class="form-check-label">
|
||||||
|
Import anyway using the renamed project title shown above.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="form-check import-duplicate-confirm mt-2">
|
||||||
|
<input asp-for="ConfirmImport" class="form-check-input" />
|
||||||
|
<label asp-for="ConfirmImport" class="form-check-label">I understand this will create new records in PlotLine.</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary mt-3" type="submit" disabled="@(!preview.Validation.IsValid)">Import project</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|||||||
39
PlotLine/Views/Imports/Result.cshtml
Normal file
39
PlotLine/Views/Imports/Result.cshtml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
@model ImportResult
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Import Complete";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="page-heading compact">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Import result</p>
|
||||||
|
<h1>Import complete</h1>
|
||||||
|
<p class="lead-text">@Model.ProjectName was created successfully.</p>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<a class="btn btn-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open imported project</a>
|
||||||
|
<a class="btn btn-outline-secondary" asp-action="Index">Import another JSON package</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="list-section import-result-section">
|
||||||
|
<div class="import-counts">
|
||||||
|
<span><strong>@Model.BooksCreated</strong> books created</span>
|
||||||
|
<span><strong>@Model.ChaptersCreated</strong> chapters created</span>
|
||||||
|
<span><strong>@Model.ScenesCreated</strong> scenes created</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.SkippedOptionalFields.Any())
|
||||||
|
{
|
||||||
|
<div class="alert alert-info mt-3">
|
||||||
|
<strong>Optional fields handled as plain text</strong>
|
||||||
|
<ul class="mb-0 mt-2">
|
||||||
|
@foreach (var item in Model.SkippedOptionalFields)
|
||||||
|
{
|
||||||
|
<li>@item</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="muted mt-3 mb-0">The import was transactional, so all listed records were created together.</p>
|
||||||
|
</section>
|
||||||
@ -106,7 +106,9 @@ a:hover {
|
|||||||
.warning-mini-card,
|
.warning-mini-card,
|
||||||
.scenario-chapter,
|
.scenario-chapter,
|
||||||
.drag-drop-panel,
|
.drag-drop-panel,
|
||||||
.import-tree-book {
|
.import-tree-book,
|
||||||
|
.import-confirmation,
|
||||||
|
.import-docs details {
|
||||||
background: var(--plotline-surface);
|
background: var(--plotline-surface);
|
||||||
border-color: var(--plotline-border-soft);
|
border-color: var(--plotline-border-soft);
|
||||||
border-radius: var(--plotline-radius);
|
border-radius: var(--plotline-radius);
|
||||||
|
|||||||
@ -179,6 +179,10 @@ td.text-end {
|
|||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
min-height: 34rem;
|
min-height: 34rem;
|
||||||
|
max-height: 64vh;
|
||||||
|
overflow: auto;
|
||||||
|
resize: vertical;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-preview-heading,
|
.import-preview-heading,
|
||||||
@ -225,6 +229,23 @@ td.text-end {
|
|||||||
background: rgba(255, 255, 255, 0.54);
|
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 {
|
.import-tree-book h3 {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-size: 1.08rem;
|
font-size: 1.08rem;
|
||||||
@ -232,7 +253,7 @@ td.text-end {
|
|||||||
|
|
||||||
.import-tree-chapter {
|
.import-tree-chapter {
|
||||||
border-top: 1px solid var(--plotline-line);
|
border-top: 1px solid var(--plotline-line);
|
||||||
padding-top: 10px;
|
padding: 10px 0 0;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -248,6 +269,37 @@ td.text-end {
|
|||||||
background: rgba(251, 236, 211, 0.72);
|
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 {
|
.scene-editor {
|
||||||
max-width: 1180px;
|
max-width: 1180px;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user