971 lines
46 KiB
C#
971 lines
46 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($"Characters: {preview.CharacterCount}");
|
|
builder.AppendLine($"Locations: {preview.LocationCount}");
|
|
builder.AppendLine($"Plot lines: {preview.PlotLineCount}");
|
|
builder.AppendLine($"Plot threads: {preview.PlotThreadCount}");
|
|
builder.AppendLine($"Thread events: {preview.ThreadEventCount}");
|
|
builder.AppendLine($"Story assets: {preview.StoryAssetCount}");
|
|
builder.AppendLine($"Asset states: {preview.AssetStateCount}");
|
|
builder.AppendLine($"Asset events: {preview.AssetEventCount}");
|
|
builder.AppendLine($"Asset custody links: {preview.AssetCustodyCount}");
|
|
builder.AppendLine($"Scene dependencies: {preview.SceneDependencyCount}");
|
|
builder.AppendLine($"Books: {preview.BookCount}");
|
|
builder.AppendLine($"Chapters: {preview.ChapterCount}");
|
|
builder.AppendLine($"Scenes: {preview.SceneCount}");
|
|
builder.AppendLine($"Scene character appearances: {preview.SceneCharacterAppearanceCount}");
|
|
builder.AppendLine();
|
|
AppendReportSection(builder, "Errors", preview.Validation.Errors);
|
|
AppendReportSection(builder, "Warnings", preview.Validation.Warnings);
|
|
AppendReportSection(builder, "Unmatched POV names", preview.UnmatchedPovNames);
|
|
AppendReportSection(builder, "Unmatched location names", preview.UnmatchedLocationNames);
|
|
AppendReportSection(builder, "Unmatched scene character names", preview.UnmatchedSceneCharacterNames);
|
|
AppendReportSection(builder, "Unresolved thread event references", preview.UnresolvedThreadEventReferences);
|
|
AppendReportSection(builder, "Unresolved asset references", preview.UnresolvedAssetReferences);
|
|
AppendReportSection(builder, "Unmatched asset owners", preview.UnmatchedAssetOwnerNames);
|
|
AppendReportSection(builder, "Unmatched asset locations", preview.UnmatchedAssetLocationNames);
|
|
AppendReportSection(builder, "Unresolved scene dependency references", preview.UnresolvedSceneDependencyReferences);
|
|
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),
|
|
UnmatchedPovNames = FindUnmatchedPovNames(package),
|
|
UnmatchedLocationNames = FindUnmatchedLocationNames(package),
|
|
UnmatchedSceneCharacterNames = FindUnmatchedSceneCharacterNames(package),
|
|
UnresolvedThreadEventReferences = FindUnresolvedThreadEventReferences(package),
|
|
UnresolvedAssetReferences = FindUnresolvedAssetReferences(package),
|
|
UnmatchedAssetOwnerNames = FindUnmatchedAssetOwnerNames(package),
|
|
UnmatchedAssetLocationNames = FindUnmatchedAssetLocationNames(package),
|
|
UnresolvedSceneDependencyReferences = FindUnresolvedSceneDependencyReferences(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);
|
|
ValidateCharacters(package, result);
|
|
ValidateLocations(package, result);
|
|
ValidatePlotLines(package, result);
|
|
ValidateStoryAssets(package, result);
|
|
ValidateSceneDependencies(package, result);
|
|
|
|
if (string.IsNullOrWhiteSpace(package.PackageVersion))
|
|
{
|
|
result.Warnings.Add("packageVersion is missing. Version 1.0 is expected for Phase 1F.");
|
|
}
|
|
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);
|
|
|
|
foreach (var duplicate in scene.SceneCharacters
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.CharacterName))
|
|
.GroupBy(x => x.CharacterName.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} lists {duplicate.Key} more than once in sceneCharacters.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var name in FindUnmatchedPovNames(package))
|
|
{
|
|
result.Warnings.Add($"POV character \"{name}\" does not match a top-level imported character. The text will be preserved in scene notes.");
|
|
}
|
|
|
|
foreach (var name in FindUnmatchedLocationNames(package))
|
|
{
|
|
result.Warnings.Add($"Scene location \"{name}\" does not match a top-level imported location. The text will be preserved in scene notes.");
|
|
}
|
|
|
|
foreach (var name in FindUnmatchedSceneCharacterNames(package))
|
|
{
|
|
result.Warnings.Add($"Scene character \"{name}\" does not match a top-level imported character and will not create an appearance.");
|
|
}
|
|
|
|
foreach (var reference in FindUnresolvedThreadEventReferences(package))
|
|
{
|
|
result.Warnings.Add($"Thread event scene reference could not be resolved and will be skipped: {reference}");
|
|
}
|
|
|
|
foreach (var reference in FindUnresolvedAssetReferences(package))
|
|
{
|
|
result.Warnings.Add($"Asset state/event scene reference could not be resolved and will be skipped: {reference}");
|
|
}
|
|
|
|
foreach (var name in FindUnmatchedAssetOwnerNames(package))
|
|
{
|
|
result.Warnings.Add($"Story asset owner \"{name}\" does not match a top-level imported character.");
|
|
}
|
|
|
|
foreach (var name in FindUnmatchedAssetLocationNames(package))
|
|
{
|
|
result.Warnings.Add($"Story asset location \"{name}\" does not match a top-level imported location.");
|
|
}
|
|
|
|
foreach (var reference in FindUnresolvedSceneDependencyReferences(package))
|
|
{
|
|
result.Warnings.Add($"Scene dependency reference could not be resolved and will be skipped: {reference}");
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static void ValidateSceneDependencies(PlotLineImportPackage package, ImportValidationResult result)
|
|
{
|
|
foreach (var duplicate in package.SceneDependencies
|
|
.Where(dependency => HasSceneOrderReference(dependency.FromScene) && HasSceneOrderReference(dependency.ToScene))
|
|
.GroupBy(SceneDependencyKey, StringComparer.OrdinalIgnoreCase)
|
|
.Where(group => group.Count() > 1))
|
|
{
|
|
result.Warnings.Add($"Duplicate scene dependency will be imported once: {duplicate.Key}.");
|
|
}
|
|
|
|
for (var index = 0; index < package.SceneDependencies.Count; index++)
|
|
{
|
|
var dependency = package.SceneDependencies[index];
|
|
var label = $"Scene dependency {index + 1}";
|
|
|
|
if (dependency.FromScene is null)
|
|
{
|
|
result.Warnings.Add($"{label} is missing fromScene and will be skipped.");
|
|
}
|
|
else if (!HasSceneOrderReference(dependency.FromScene) && string.IsNullOrWhiteSpace(dependency.FromScene.SceneRef))
|
|
{
|
|
result.Warnings.Add($"{label} fromScene is missing bookOrder/chapterOrder/sceneOrder.");
|
|
}
|
|
|
|
if (dependency.ToScene is null)
|
|
{
|
|
result.Warnings.Add($"{label} is missing toScene and will be skipped.");
|
|
}
|
|
else if (!HasSceneOrderReference(dependency.ToScene) && string.IsNullOrWhiteSpace(dependency.ToScene.SceneRef))
|
|
{
|
|
result.Warnings.Add($"{label} toScene is missing bookOrder/chapterOrder/sceneOrder.");
|
|
}
|
|
|
|
if (HasSceneOrderReference(dependency.FromScene) && HasSceneOrderReference(dependency.ToScene)
|
|
&& SceneReferenceKey(dependency.FromScene!) == SceneReferenceKey(dependency.ToScene!))
|
|
{
|
|
result.Warnings.Add($"{label} links a scene to itself and will be skipped.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(dependency.DependencyType) && dependency.DependencyType.Trim().Length > 100)
|
|
{
|
|
result.Warnings.Add($"{label} dependencyType is longer than 100 characters and may not match an existing dependency type.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(dependency.Strength) && !IsSupportedDependencyStrength(dependency.Strength))
|
|
{
|
|
result.Warnings.Add($"{label} strength \"{dependency.Strength.Trim()}\" is not a recognized hard/soft value. It will be preserved in the description.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{label} description", dependency.Description);
|
|
ValidateLargeText(result, $"{label} notes", dependency.Notes);
|
|
}
|
|
}
|
|
|
|
private static void ValidateStoryAssets(PlotLineImportPackage package, ImportValidationResult result)
|
|
{
|
|
foreach (var group in package.StoryAssets
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"Story asset \"{group.Key}\" appears more than once in the import package.");
|
|
}
|
|
|
|
for (var assetIndex = 0; assetIndex < package.StoryAssets.Count; assetIndex++)
|
|
{
|
|
var asset = package.StoryAssets[assetIndex];
|
|
var assetLabel = string.IsNullOrWhiteSpace(asset.Name) ? $"Story asset {assetIndex + 1}" : asset.Name.Trim();
|
|
if (string.IsNullOrWhiteSpace(asset.Name))
|
|
{
|
|
result.Errors.Add($"Story asset {assetIndex + 1} is missing a name.");
|
|
}
|
|
|
|
if (DisplayName(asset.DisplayName, asset.Name).Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{assetLabel} name/displayName is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{assetLabel} description", asset.Description);
|
|
ValidateLargeText(result, $"{assetLabel} notes", asset.Notes);
|
|
|
|
foreach (var duplicate in asset.States
|
|
.Where(HasSceneOrderReference)
|
|
.GroupBy(AssetStateSceneKey)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"{assetLabel} has duplicate states for scene {duplicate.Key}.");
|
|
}
|
|
|
|
foreach (var duplicate in asset.Events
|
|
.Where(HasSceneOrderReference)
|
|
.GroupBy(AssetEventSceneKey)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"{assetLabel} has duplicate events for scene {duplicate.Key}.");
|
|
}
|
|
|
|
for (var stateIndex = 0; stateIndex < asset.States.Count; stateIndex++)
|
|
{
|
|
var state = asset.States[stateIndex];
|
|
var stateLabel = string.IsNullOrWhiteSpace(state.StateName) ? $"State {stateIndex + 1}" : state.StateName.Trim();
|
|
if (string.IsNullOrWhiteSpace(state.StateName) && string.IsNullOrWhiteSpace(state.Description))
|
|
{
|
|
result.Errors.Add($"{assetLabel} state {stateIndex + 1} needs stateName or description.");
|
|
}
|
|
|
|
if (!HasSceneOrderReference(state) && string.IsNullOrWhiteSpace(state.SceneRef))
|
|
{
|
|
result.Warnings.Add($"{assetLabel} / {stateLabel} is missing bookOrder/chapterOrder/sceneOrder.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(state.StateName) && state.StateName.Trim().Length > 100)
|
|
{
|
|
result.Warnings.Add($"{assetLabel} / {stateLabel} stateName is longer than 100 characters and may not match an existing asset state.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{assetLabel} / {stateLabel} description", state.Description);
|
|
ValidateLargeText(result, $"{assetLabel} / {stateLabel} notes", state.Notes);
|
|
}
|
|
|
|
for (var eventIndex = 0; eventIndex < asset.Events.Count; eventIndex++)
|
|
{
|
|
var assetEvent = asset.Events[eventIndex];
|
|
var eventLabel = string.IsNullOrWhiteSpace(assetEvent.Title) ? $"Event {eventIndex + 1}" : assetEvent.Title.Trim();
|
|
if (string.IsNullOrWhiteSpace(assetEvent.Title) && string.IsNullOrWhiteSpace(assetEvent.Summary))
|
|
{
|
|
result.Errors.Add($"{assetLabel} event {eventIndex + 1} needs a title or summary.");
|
|
}
|
|
|
|
if (!HasSceneOrderReference(assetEvent) && string.IsNullOrWhiteSpace(assetEvent.SceneRef))
|
|
{
|
|
result.Warnings.Add($"{assetLabel} / {eventLabel} is missing bookOrder/chapterOrder/sceneOrder.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(assetEvent.Title) && assetEvent.Title.Trim().Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{assetLabel} / {eventLabel} title is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{assetLabel} / {eventLabel} summary", assetEvent.Summary);
|
|
ValidateLargeText(result, $"{assetLabel} / {eventLabel} notes", assetEvent.Notes);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ValidatePlotLines(PlotLineImportPackage package, ImportValidationResult result)
|
|
{
|
|
foreach (var group in package.PlotLines
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"Plot line \"{group.Key}\" appears more than once in the import package.");
|
|
}
|
|
|
|
for (var plotIndex = 0; plotIndex < package.PlotLines.Count; plotIndex++)
|
|
{
|
|
var plotLine = package.PlotLines[plotIndex];
|
|
var plotLabel = string.IsNullOrWhiteSpace(plotLine.Name) ? $"Plot line {plotIndex + 1}" : plotLine.Name.Trim();
|
|
if (string.IsNullOrWhiteSpace(plotLine.Name))
|
|
{
|
|
result.Errors.Add($"Plot line {plotIndex + 1} is missing a name.");
|
|
}
|
|
|
|
if (DisplayName(plotLine.DisplayName, plotLine.Name).Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{plotLabel} name/displayName is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{plotLabel} description", plotLine.Description);
|
|
ValidateLargeText(result, $"{plotLabel} notes", plotLine.Notes);
|
|
|
|
foreach (var group in plotLine.Threads
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"{plotLabel} has duplicate thread \"{group.Key}\".");
|
|
}
|
|
|
|
for (var threadIndex = 0; threadIndex < plotLine.Threads.Count; threadIndex++)
|
|
{
|
|
var thread = plotLine.Threads[threadIndex];
|
|
var threadLabel = string.IsNullOrWhiteSpace(thread.Name) ? $"Thread {threadIndex + 1}" : thread.Name.Trim();
|
|
if (string.IsNullOrWhiteSpace(thread.Name))
|
|
{
|
|
result.Errors.Add($"{plotLabel} thread {threadIndex + 1} is missing a name.");
|
|
}
|
|
|
|
if (DisplayName(thread.DisplayName, thread.Name).Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{plotLabel} / {threadLabel} name/displayName is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{plotLabel} / {threadLabel} description", thread.Description);
|
|
ValidateLargeText(result, $"{plotLabel} / {threadLabel} notes", thread.Notes);
|
|
|
|
foreach (var duplicate in thread.Events
|
|
.Where(HasSceneOrderReference)
|
|
.GroupBy(EventSceneKey)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"{plotLabel} / {threadLabel} has duplicate events for scene {duplicate.Key}.");
|
|
}
|
|
|
|
for (var eventIndex = 0; eventIndex < thread.Events.Count; eventIndex++)
|
|
{
|
|
var threadEvent = thread.Events[eventIndex];
|
|
var eventLabel = string.IsNullOrWhiteSpace(threadEvent.Title) ? $"Event {eventIndex + 1}" : threadEvent.Title.Trim();
|
|
if (!HasSceneOrderReference(threadEvent) && string.IsNullOrWhiteSpace(threadEvent.SceneRef))
|
|
{
|
|
result.Warnings.Add($"{plotLabel} / {threadLabel} / {eventLabel} is missing bookOrder/chapterOrder/sceneOrder.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(threadEvent.Title) && string.IsNullOrWhiteSpace(threadEvent.Summary))
|
|
{
|
|
result.Errors.Add($"{plotLabel} / {threadLabel} event {eventIndex + 1} needs a title or summary.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(threadEvent.Title) && threadEvent.Title.Trim().Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{plotLabel} / {threadLabel} / {eventLabel} title is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{plotLabel} / {threadLabel} / {eventLabel} summary", threadEvent.Summary);
|
|
ValidateLargeText(result, $"{plotLabel} / {threadLabel} / {eventLabel} impact", threadEvent.Impact);
|
|
ValidateLargeText(result, $"{plotLabel} / {threadLabel} / {eventLabel} notes", threadEvent.Notes);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ValidateCharacters(PlotLineImportPackage package, ImportValidationResult result)
|
|
{
|
|
foreach (var group in package.Characters
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"Character \"{group.Key}\" appears more than once in the import package.");
|
|
}
|
|
|
|
for (var index = 0; index < package.Characters.Count; index++)
|
|
{
|
|
var character = package.Characters[index];
|
|
var label = string.IsNullOrWhiteSpace(character.Name) ? $"Character {index + 1}" : character.Name.Trim();
|
|
if (string.IsNullOrWhiteSpace(character.Name))
|
|
{
|
|
result.Errors.Add($"Character {index + 1} is missing a name.");
|
|
}
|
|
|
|
if (DisplayName(character.DisplayName, character.Name).Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{label} name/displayName is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(character.ShortName) && character.ShortName.Trim().Length > 100)
|
|
{
|
|
result.Errors.Add($"{label} shortName is longer than 100 characters.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{label} description", character.Description);
|
|
ValidateLargeText(result, $"{label} notes", character.Notes);
|
|
}
|
|
}
|
|
|
|
private static void ValidateLocations(PlotLineImportPackage package, ImportValidationResult result)
|
|
{
|
|
var names = package.Locations
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.Select(x => x.Name.Trim())
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var group in package.Locations
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"Location \"{group.Key}\" appears more than once in the import package.");
|
|
}
|
|
|
|
for (var index = 0; index < package.Locations.Count; index++)
|
|
{
|
|
var location = package.Locations[index];
|
|
var label = string.IsNullOrWhiteSpace(location.Name) ? $"Location {index + 1}" : location.Name.Trim();
|
|
if (string.IsNullOrWhiteSpace(location.Name))
|
|
{
|
|
result.Errors.Add($"Location {index + 1} is missing a name.");
|
|
}
|
|
|
|
if (DisplayName(location.DisplayName, location.Name).Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{label} name/displayName is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(location.Type) && location.Type.Trim().Length > 100)
|
|
{
|
|
result.Warnings.Add($"{label} location type is longer than 100 characters and may not match an existing type.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(location.ParentLocationName) && !names.Contains(location.ParentLocationName.Trim()))
|
|
{
|
|
result.Warnings.Add($"{label} references parentLocationName \"{location.ParentLocationName.Trim()}\", but that location is not in this package. It will be imported without a parent.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{label} description", location.Description);
|
|
ValidateLargeText(result, $"{label} notes", location.Notes);
|
|
}
|
|
}
|
|
|
|
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 that match top-level characters will be linked to scenes. Unmatched POV names stay as plain text notes.");
|
|
}
|
|
|
|
if (hasLocation)
|
|
{
|
|
validation.Warnings.Add("Scene location names that match top-level locations will be linked as primary locations. Unmatched location names stay as plain text notes.");
|
|
}
|
|
}
|
|
|
|
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 IReadOnlyList<string> FindUnmatchedPovNames(PlotLineImportPackage package)
|
|
{
|
|
var characterNames = CharacterNames(package);
|
|
return package.Books
|
|
.SelectMany(book => book.Chapters)
|
|
.SelectMany(chapter => chapter.Scenes)
|
|
.Select(scene => scene.PovCharacterName?.Trim())
|
|
.Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(name => name)
|
|
.ToList()!;
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnmatchedLocationNames(PlotLineImportPackage package)
|
|
{
|
|
var locationNames = LocationNames(package);
|
|
return package.Books
|
|
.SelectMany(book => book.Chapters)
|
|
.SelectMany(chapter => chapter.Scenes)
|
|
.Select(scene => scene.LocationName?.Trim())
|
|
.Where(name => !string.IsNullOrWhiteSpace(name) && !locationNames.Contains(name))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(name => name)
|
|
.ToList()!;
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnmatchedSceneCharacterNames(PlotLineImportPackage package)
|
|
{
|
|
var characterNames = CharacterNames(package);
|
|
return package.Books
|
|
.SelectMany(book => book.Chapters)
|
|
.SelectMany(chapter => chapter.Scenes)
|
|
.SelectMany(scene => scene.SceneCharacters)
|
|
.Select(sceneCharacter => sceneCharacter.CharacterName?.Trim())
|
|
.Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(name => name)
|
|
.ToList()!;
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnresolvedThreadEventReferences(PlotLineImportPackage package)
|
|
{
|
|
var sceneKeys = package.Books
|
|
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => $"{book.SeriesOrder}/{chapter.Order}/{scene.Order}")))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
return package.PlotLines
|
|
.SelectMany(plotLine => plotLine.Threads.SelectMany(thread => thread.Events.Select((threadEvent, index) => new
|
|
{
|
|
PlotLineName = DisplayName(plotLine.DisplayName, plotLine.Name),
|
|
ThreadName = DisplayName(thread.DisplayName, thread.Name),
|
|
EventTitle = string.IsNullOrWhiteSpace(threadEvent.Title) ? $"Event {index + 1}" : threadEvent.Title.Trim(),
|
|
Event = threadEvent
|
|
})))
|
|
.Where(x => !HasSceneOrderReference(x.Event) || !sceneKeys.Contains(EventSceneKey(x.Event)))
|
|
.Select(x => $"{x.PlotLineName} / {x.ThreadName} / {x.EventTitle} -> {EventSceneKey(x.Event)}")
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(x => x)
|
|
.ToList();
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnresolvedAssetReferences(PlotLineImportPackage package)
|
|
{
|
|
var sceneKeys = package.Books
|
|
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => $"{book.SeriesOrder}/{chapter.Order}/{scene.Order}")))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
var stateReferences = package.StoryAssets
|
|
.SelectMany(asset => asset.States.Select((state, index) => new
|
|
{
|
|
AssetName = DisplayName(asset.DisplayName, asset.Name),
|
|
Label = string.IsNullOrWhiteSpace(state.StateName) ? $"State {index + 1}" : state.StateName.Trim(),
|
|
State = state
|
|
}))
|
|
.Where(x => !HasSceneOrderReference(x.State) || !sceneKeys.Contains(AssetStateSceneKey(x.State)))
|
|
.Select(x => $"{x.AssetName} / state / {x.Label} -> {AssetStateSceneKey(x.State)}");
|
|
|
|
var eventReferences = package.StoryAssets
|
|
.SelectMany(asset => asset.Events.Select((assetEvent, index) => new
|
|
{
|
|
AssetName = DisplayName(asset.DisplayName, asset.Name),
|
|
Label = string.IsNullOrWhiteSpace(assetEvent.Title) ? $"Event {index + 1}" : assetEvent.Title.Trim(),
|
|
Event = assetEvent
|
|
}))
|
|
.Where(x => !HasSceneOrderReference(x.Event) || !sceneKeys.Contains(AssetEventSceneKey(x.Event)))
|
|
.Select(x => $"{x.AssetName} / event / {x.Label} -> {AssetEventSceneKey(x.Event)}");
|
|
|
|
return stateReferences
|
|
.Concat(eventReferences)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(x => x)
|
|
.ToList();
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnmatchedAssetOwnerNames(PlotLineImportPackage package)
|
|
{
|
|
var characterNames = CharacterNames(package);
|
|
return package.StoryAssets
|
|
.Select(asset => asset.InitialOwnerCharacterName?.Trim())
|
|
.Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.CharacterName?.Trim())))
|
|
.Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(name => name)
|
|
.ToList()!;
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnmatchedAssetLocationNames(PlotLineImportPackage package)
|
|
{
|
|
var locationNames = LocationNames(package);
|
|
return package.StoryAssets
|
|
.Select(asset => asset.InitialLocationName?.Trim())
|
|
.Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.LocationName?.Trim())))
|
|
.Where(name => !string.IsNullOrWhiteSpace(name) && !locationNames.Contains(name))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(name => name)
|
|
.ToList()!;
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnresolvedSceneDependencyReferences(PlotLineImportPackage package)
|
|
{
|
|
var sceneKeys = package.Books
|
|
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => $"{book.SeriesOrder}/{chapter.Order}/{scene.Order}")))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
return package.SceneDependencies
|
|
.SelectMany((dependency, index) => new[]
|
|
{
|
|
new
|
|
{
|
|
Label = $"Dependency {index + 1} fromScene",
|
|
Reference = dependency.FromScene
|
|
},
|
|
new
|
|
{
|
|
Label = $"Dependency {index + 1} toScene",
|
|
Reference = dependency.ToScene
|
|
}
|
|
})
|
|
.Where(x => !HasSceneOrderReference(x.Reference) || !sceneKeys.Contains(SceneReferenceKey(x.Reference!)))
|
|
.Select(x => $"{x.Label} -> {SceneReferenceKey(x.Reference)}")
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(x => x)
|
|
.ToList();
|
|
}
|
|
|
|
private static bool HasSceneOrderReference(ImportThreadEventDto threadEvent)
|
|
=> threadEvent.BookOrder.HasValue && threadEvent.ChapterOrder.HasValue && threadEvent.SceneOrder.HasValue;
|
|
|
|
private static string EventSceneKey(ImportThreadEventDto threadEvent)
|
|
=> $"{threadEvent.BookOrder?.ToString() ?? "?"}/{threadEvent.ChapterOrder?.ToString() ?? "?"}/{threadEvent.SceneOrder?.ToString() ?? "?"}";
|
|
|
|
private static bool HasSceneOrderReference(ImportAssetStateDto state)
|
|
=> state.BookOrder.HasValue && state.ChapterOrder.HasValue && state.SceneOrder.HasValue;
|
|
|
|
private static bool HasSceneOrderReference(ImportAssetEventDto assetEvent)
|
|
=> assetEvent.BookOrder.HasValue && assetEvent.ChapterOrder.HasValue && assetEvent.SceneOrder.HasValue;
|
|
|
|
private static string AssetStateSceneKey(ImportAssetStateDto state)
|
|
=> $"{state.BookOrder?.ToString() ?? "?"}/{state.ChapterOrder?.ToString() ?? "?"}/{state.SceneOrder?.ToString() ?? "?"}";
|
|
|
|
private static string AssetEventSceneKey(ImportAssetEventDto assetEvent)
|
|
=> $"{assetEvent.BookOrder?.ToString() ?? "?"}/{assetEvent.ChapterOrder?.ToString() ?? "?"}/{assetEvent.SceneOrder?.ToString() ?? "?"}";
|
|
|
|
private static bool HasSceneOrderReference(ImportSceneReferenceDto? reference)
|
|
=> reference?.BookOrder.HasValue == true && reference.ChapterOrder.HasValue && reference.SceneOrder.HasValue;
|
|
|
|
private static string SceneReferenceKey(ImportSceneReferenceDto? reference)
|
|
=> reference is null
|
|
? "(missing)"
|
|
: $"{reference.BookOrder?.ToString() ?? "?"}/{reference.ChapterOrder?.ToString() ?? "?"}/{reference.SceneOrder?.ToString() ?? "?"}";
|
|
|
|
private static string SceneDependencyKey(ImportSceneDependencyDto dependency)
|
|
=> $"{SceneReferenceKey(dependency.FromScene)} -> {SceneReferenceKey(dependency.ToScene)} / {CleanDependencyType(dependency.DependencyType)}";
|
|
|
|
private static string CleanDependencyType(string? dependencyType)
|
|
=> string.IsNullOrWhiteSpace(dependencyType) ? "Must Occur Before" : dependencyType.Trim();
|
|
|
|
private static bool IsSupportedDependencyStrength(string strength)
|
|
{
|
|
var normalized = strength.Trim();
|
|
return normalized.Equals("hard", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("soft", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("strong", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("weak", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("required", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("optional", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("high", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("medium", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("low", StringComparison.OrdinalIgnoreCase)
|
|
|| (int.TryParse(normalized, out var numeric) && numeric is >= 1 and <= 10);
|
|
}
|
|
|
|
private static HashSet<string> CharacterNames(PlotLineImportPackage package)
|
|
=> package.Characters
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.Select(x => x.Name.Trim())
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private static HashSet<string> LocationNames(PlotLineImportPackage package)
|
|
=> package.Locations
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.Select(x => x.Name.Trim())
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
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 string DisplayName(string? displayName, string fallback)
|
|
=> string.IsNullOrWhiteSpace(displayName) ? fallback.Trim() : displayName.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;
|
|
}
|
|
}
|