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); } public sealed class ImportService(IImportRepository imports) : IImportService { 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 (preview.HasDuplicateProjectTitle && !model.AllowDuplicateProjectTitle) { return new ImportResult { Succeeded = false, Message = "A project with this title already exists. Confirm that you want to import another project with the same title." }; } try { return await imports.ImportAsync(preview.Package); } catch (Exception ex) { return new ImportResult { Succeeded = false, Message = $"The import could not be completed, so no data was saved. {ex.Message}" }; } } private async Task 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()); if (duplicate) { validation.Warnings.Add("A project with the same title already exists. Import is blocked until you confirm that duplicate title."); } return new ImportPreview { Package = package, Validation = validation, HasDuplicateProjectTitle = duplicate }; } private static ImportValidationResult Validate(PlotLineImportPackage package) { var result = new ImportValidationResult(); if (string.IsNullOrWhiteSpace(package.Project.Title)) { result.Errors.Add("Project title is required."); } if (package.Books.Count == 0) { result.Warnings.Add("No books were supplied. The import will create an empty project."); } foreach (var group in package.Books.GroupBy(x => x.SeriesOrder).Where(x => x.Count() > 1)) { result.Errors.Add($"Book order {group.Key} is used more than once."); } for (var bookIndex = 0; bookIndex < package.Books.Count; bookIndex++) { var book = package.Books[bookIndex]; var bookLabel = string.IsNullOrWhiteSpace(book.Title) ? $"Book {bookIndex + 1}" : book.Title; if (string.IsNullOrWhiteSpace(book.Title)) { result.Errors.Add($"Book {bookIndex + 1} is missing a title."); } foreach (var group in book.Chapters.GroupBy(x => x.Order).Where(x => x.Count() > 1)) { result.Errors.Add($"{bookLabel} has duplicate chapter order {group.Key}."); } for (var chapterIndex = 0; chapterIndex < book.Chapters.Count; chapterIndex++) { var chapter = book.Chapters[chapterIndex]; var chapterLabel = string.IsNullOrWhiteSpace(chapter.Title) ? $"chapter {chapterIndex + 1}" : chapter.Title; if (string.IsNullOrWhiteSpace(chapter.Title)) { result.Errors.Add($"{bookLabel} chapter {chapterIndex + 1} is missing a title."); } foreach (var group in chapter.Scenes.GroupBy(x => x.Order).Where(x => x.Count() > 1)) { result.Errors.Add($"{bookLabel} / {chapterLabel} has duplicate scene order {group.Key}."); } for (var sceneIndex = 0; sceneIndex < chapter.Scenes.Count; sceneIndex++) { if (string.IsNullOrWhiteSpace(chapter.Scenes[sceneIndex].Title)) { result.Errors.Add($"{bookLabel} / {chapterLabel} scene {sceneIndex + 1} is missing a title."); } } } } return result; } private static void AddMetadataWarnings(PlotLineImportPackage package, ImportValidationResult validation) { var hasPov = package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName)))); var hasLocation = package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName)))); if (hasPov) { validation.Warnings.Add("POV character names will be kept as imported text notes in scene summaries. Character records are not created in Phase 1A."); } if (hasLocation) { validation.Warnings.Add("Location names will be kept as imported text notes in scene summaries. Location records are not created in Phase 1A."); } } private static async Task 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; } }