using System.Text; using System.Text.Json; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IImportService { Task PreviewAsync(ImportIndexViewModel model); Task ImportAsync(ImportIndexViewModel model); Task BuildValidationReportAsync(ImportIndexViewModel model); } 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, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }; public async Task PreviewAsync(ImportIndexViewModel model) { model.JsonText = await ReadJsonTextAsync(model); model.Preview = await BuildPreviewAsync(model.JsonText); return model; } public async Task 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 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)) { 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(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 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 }) { using var reader = new StreamReader(model.JsonFile.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); return await reader.ReadToEndAsync(); } return model.JsonText ?? string.Empty; } }