PlotDirector/PlotLine/Services/ImportServices.cs
2026-06-01 20:47:20 +01:00

202 lines
7.3 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);
}
public sealed class ImportService(IImportRepository imports) : IImportService
{
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 (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<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());
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<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;
}
}