374 lines
15 KiB
C#
374 lines
15 KiB
C#
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);
|
|
Task<string> BuildValidationReportAsync(ImportIndexViewModel model);
|
|
}
|
|
|
|
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()
|
|
{
|
|
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 (!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
|
|
{
|
|
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, 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.",
|
|
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)
|
|
{
|
|
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());
|
|
|
|
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. If imported anyway, the new project will be named \"{duplicateTitle}\".");
|
|
}
|
|
|
|
return new ImportPreview
|
|
{
|
|
Package = package,
|
|
Validation = validation,
|
|
HasDuplicateProjectTitle = duplicate,
|
|
DuplicateImportProjectTitle = duplicateTitle,
|
|
BookPreviews = BuildBookPreviews(package)
|
|
};
|
|
}
|
|
|
|
private static ImportValidationResult Validate(PlotLineImportPackage package)
|
|
{
|
|
var result = new ImportValidationResult();
|
|
|
|
if (string.IsNullOrWhiteSpace(package.Project.Title))
|
|
{
|
|
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.");
|
|
}
|
|
|
|
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.");
|
|
}
|
|
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))
|
|
{
|
|
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.");
|
|
}
|
|
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))
|
|
{
|
|
result.Errors.Add($"{bookLabel} / {chapterLabel} has duplicate scene order {group.Key}.");
|
|
}
|
|
|
|
for (var sceneIndex = 0; sceneIndex < chapter.Scenes.Count; sceneIndex++)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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 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)
|
|
{
|
|
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;
|
|
}
|
|
}
|