Importer Phase 1G

This commit is contained in:
Nick Beckley 2026-06-02 13:07:05 +01:00
parent b684146248
commit ef5688bb88
7 changed files with 712 additions and 83 deletions

View File

@ -28,7 +28,7 @@ public sealed class ImportsController(IImportService imports) : Controller
public async Task<IActionResult> Import(ImportIndexViewModel model)
{
var result = await imports.ImportAsync(model);
if (result.Succeeded && result.ProjectID.HasValue)
if (result.Succeeded && (result.ProjectID.HasValue || result.IsDryRun))
{
return View("Result", result);
}

View File

@ -76,7 +76,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
foreach (var character in package.Characters)
{
var characterName = Clean(DisplayName(character.DisplayName, character.Name));
if (characterName.Length == 0 || characterMap.ContainsKey(character.Name.Trim()))
if (characterName.Length == 0 || ResolveId(characterMap, character.Name, package.Aliases.Characters).HasValue)
{
continue;
}
@ -105,7 +105,8 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
transaction,
commandType: CommandType.StoredProcedure);
characterMap[character.Name.Trim()] = characterId;
AddLookupKeys(characterMap, character.Name, characterId);
AddLookupKeys(characterMap, character.DisplayName, characterId);
charactersCreated++;
}
@ -113,7 +114,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
foreach (var location in package.Locations)
{
var locationName = Clean(DisplayName(location.DisplayName, location.Name));
if (locationName.Length == 0 || locationMap.ContainsKey(location.Name.Trim()))
if (locationName.Length == 0 || ResolveId(locationMap, location.Name, package.Aliases.Locations).HasValue)
{
continue;
}
@ -132,14 +133,16 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
transaction,
commandType: CommandType.StoredProcedure);
locationMap[location.Name.Trim()] = locationId;
AddLookupKeys(locationMap, location.Name, locationId);
AddLookupKeys(locationMap, location.DisplayName, locationId);
locationsCreated++;
}
foreach (var location in package.Locations.Where(x => !string.IsNullOrWhiteSpace(x.ParentLocationName)))
{
if (!locationMap.TryGetValue(location.Name.Trim(), out var locationId)
|| !locationMap.TryGetValue(location.ParentLocationName!.Trim(), out var parentLocationId))
var locationId = ResolveId(locationMap, location.Name, package.Aliases.Locations);
var parentLocation = ResolveId(locationMap, location.ParentLocationName, package.Aliases.Locations);
if (!locationId.HasValue || !parentLocation.HasValue)
{
continue;
}
@ -148,9 +151,9 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
"dbo.Location_Save",
new
{
LocationID = locationId,
LocationID = locationId.Value,
ProjectID = projectId,
ParentLocationID = parentLocationId,
ParentLocationID = parentLocation.Value,
LocationName = Clean(DisplayName(location.DisplayName, location.Name)),
LocationTypeID = LookupLocationTypeId(lookupIds, location.Type),
Description = CombineText(location.Description, location.Notes, ImportMarker)
@ -198,8 +201,8 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
foreach (var scene in chapter.Scenes.OrderBy(x => x.Order))
{
var primaryLocationId = ResolveId(locationMap, scene.LocationName);
var povCharacterId = ResolveId(characterMap, scene.PovCharacterName);
var primaryLocationId = ResolveId(locationMap, scene.LocationName, package.Aliases.Locations);
var povCharacterId = ResolveId(characterMap, scene.PovCharacterName, package.Aliases.Characters);
var sceneId = await connection.QuerySingleAsync<int>(
"dbo.Scene_Save",
new
@ -236,7 +239,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
transaction);
}
foreach (var appearance in BuildSceneAppearances(scene, povCharacterId, characterMap))
foreach (var appearance in BuildSceneAppearances(scene, povCharacterId, characterMap, package.Aliases.Characters))
{
await connection.QuerySingleAsync<int>(
"dbo.SceneCharacter_Save",
@ -368,8 +371,8 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
continue;
}
var initialLocationId = ResolveId(locationMap, storyAsset.InitialLocationName);
var initialOwnerCharacterId = ResolveId(characterMap, storyAsset.InitialOwnerCharacterName);
var initialLocationId = ResolveId(locationMap, storyAsset.InitialLocationName, package.Aliases.Locations);
var initialOwnerCharacterId = ResolveId(characterMap, storyAsset.InitialOwnerCharacterName, package.Aliases.Characters);
var storyAssetId = await connection.QuerySingleAsync<int>(
"dbo.StoryAsset_Save",
new
@ -448,7 +451,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
continue;
}
var eventLocationId = ResolveId(locationMap, assetEvent.LocationName);
var eventLocationId = ResolveId(locationMap, assetEvent.LocationName, package.Aliases.Locations);
if (eventLocationId.HasValue)
{
await connection.QuerySingleAsync<int>(
@ -551,6 +554,8 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
AssetEventsCreated = assetEventsCreated,
AssetCustodyEventsCreated = assetCustodyEventsCreated,
SceneDependenciesCreated = sceneDependenciesCreated,
SkippedItemCount = skippedFields.Count,
UnresolvedReferenceCount = unresolvedWarnings.Count,
SkippedOptionalFields = skippedFields,
UnresolvedWarnings = unresolvedWarnings,
Message = "Import complete."
@ -645,27 +650,27 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
private static string ImportMetadata(PlotLineImportPackage package)
{
var source = string.IsNullOrWhiteSpace(package.Source) ? null : $"Source: {package.Source.Trim()}";
return CombineText(ImportMarker, $"Imported at {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC", source) ?? ImportMarker;
return CombineText(
ImportMarker,
$"Imported at {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC",
source,
MetadataLine("Package created by", package.CreatedBy),
MetadataLine("Package created date", package.CreatedDate),
MetadataLine("Package description", package.Description)) ?? ImportMarker;
}
private static IReadOnlyList<string> GetSkippedOptionalFields(PlotLineImportPackage package)
{
var skipped = new List<string>();
var characterNames = package.Characters
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
.Select(x => x.Name.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var locationNames = package.Locations
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
.Select(x => x.Name.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var characterNames = BuildNameKeySet(package.Characters.SelectMany(x => new[] { x.Name, x.DisplayName }));
var locationNames = BuildNameKeySet(package.Locations.SelectMany(x => new[] { x.Name, x.DisplayName }));
if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName) && !characterNames.Contains(scene.PovCharacterName.Trim())))))
if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName) && !ContainsNameKey(characterNames, ResolveAlias(scene.PovCharacterName, package.Aliases.Characters))))))
{
skipped.Add("POV character names were stored as plain text in scene summaries; Character records were not created.");
}
if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName) && !locationNames.Contains(scene.LocationName.Trim())))))
if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName) && !ContainsNameKey(locationNames, ResolveAlias(scene.LocationName, package.Aliases.Locations))))))
{
skipped.Add("Location names were stored as plain text in scene summaries; Location records were not created.");
}
@ -696,26 +701,20 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
private static IReadOnlyList<string> GetUnresolvedWarnings(PlotLineImportPackage package)
{
var warnings = new List<string>();
var characterNames = package.Characters
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
.Select(x => x.Name.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var locationNames = package.Locations
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
.Select(x => x.Name.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var characterNames = BuildNameKeySet(package.Characters.SelectMany(x => new[] { x.Name, x.DisplayName }));
var locationNames = BuildNameKeySet(package.Locations.SelectMany(x => new[] { x.Name, x.DisplayName }));
foreach (var name in 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))
foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.PovCharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(characterNames, ResolveAlias(name, package.Aliases.Characters))).Distinct(StringComparer.OrdinalIgnoreCase))
{
warnings.Add($"Unmatched POV character: {name}");
}
foreach (var name in 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))
foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.LocationName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(locationNames, ResolveAlias(name, package.Aliases.Locations))).Distinct(StringComparer.OrdinalIgnoreCase))
{
warnings.Add($"Unmatched scene location: {name}");
}
foreach (var name in 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))
foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).SelectMany(scene => scene.SceneCharacters).Select(sceneCharacter => sceneCharacter.CharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(characterNames, ResolveAlias(name, package.Aliases.Characters))).Distinct(StringComparer.OrdinalIgnoreCase))
{
warnings.Add($"Unmatched scene character: {name}");
}
@ -739,7 +738,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
foreach (var name in 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))
.Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(characterNames, ResolveAlias(name, package.Aliases.Characters)))
.Distinct(StringComparer.OrdinalIgnoreCase))
{
warnings.Add($"Unmatched asset character: {name}");
@ -747,7 +746,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
foreach (var name in 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))
.Where(name => !string.IsNullOrWhiteSpace(name) && !ContainsNameKey(locationNames, ResolveAlias(name, package.Aliases.Locations)))
.Distinct(StringComparer.OrdinalIgnoreCase))
{
warnings.Add($"Unmatched asset location: {name}");
@ -797,18 +796,19 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
return warnings;
}
private static IEnumerable<ImportAppearance> BuildSceneAppearances(ImportSceneDto scene, int? povCharacterId, IReadOnlyDictionary<string, int> characterMap)
private static IEnumerable<ImportAppearance> BuildSceneAppearances(ImportSceneDto scene, int? povCharacterId, IReadOnlyDictionary<string, int> characterMap, Dictionary<string, string>? aliases)
{
var appearances = new Dictionary<int, ImportAppearance>();
foreach (var sceneCharacter in scene.SceneCharacters)
{
if (string.IsNullOrWhiteSpace(sceneCharacter.CharacterName)
|| !characterMap.TryGetValue(sceneCharacter.CharacterName.Trim(), out var characterId))
|| !ResolveId(characterMap, sceneCharacter.CharacterName, aliases).HasValue)
{
continue;
}
var characterId = ResolveId(characterMap, sceneCharacter.CharacterName, aliases)!.Value;
appearances[characterId] = new ImportAppearance(characterId, sceneCharacter.RoleInScene, sceneCharacter.Importance, sceneCharacter.Notes);
}
@ -820,8 +820,129 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
return appearances.Values;
}
private static int? ResolveId(IReadOnlyDictionary<string, int> map, string? name)
=> !string.IsNullOrWhiteSpace(name) && map.TryGetValue(name.Trim(), out var id) ? id : null;
private static int? ResolveId(IReadOnlyDictionary<string, int> map, string? name, Dictionary<string, string>? aliases)
{
var resolvedName = ResolveAlias(name, aliases);
foreach (var key in CandidateNameKeys(resolvedName))
{
if (map.TryGetValue(key, out var id) && id > 0)
{
return id;
}
}
return null;
}
private static void AddLookupKeys(Dictionary<string, int> map, string? name, int id)
{
foreach (var key in CandidateNameKeys(name))
{
if (map.TryGetValue(key, out var existingId) && existingId != id)
{
map[key] = 0;
}
else
{
map[key] = id;
}
}
}
private static HashSet<string> BuildNameKeySet(IEnumerable<string?> names)
{
var keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in names)
{
foreach (var key in CandidateNameKeys(name))
{
keys.Add(key);
}
}
return keys;
}
private static bool ContainsNameKey(HashSet<string> keys, string? name)
=> CandidateNameKeys(name).Any(keys.Contains);
private static string ResolveAlias(string? name, Dictionary<string, string>? aliases)
{
if (string.IsNullOrWhiteSpace(name))
{
return string.Empty;
}
var clean = CollapseSpaces(name);
if (aliases is null || aliases.Count == 0)
{
return clean;
}
foreach (var key in CandidateNameKeys(clean))
{
foreach (var alias in aliases)
{
if (CandidateNameKeys(alias.Key).Contains(key, StringComparer.OrdinalIgnoreCase))
{
return CollapseSpaces(alias.Value);
}
}
}
return clean;
}
private static IEnumerable<string> CandidateNameKeys(string? name)
{
var normalized = NormalizeNameKey(name);
if (normalized.Length == 0)
{
yield break;
}
yield return normalized;
var punctuationInsensitive = new string(normalized.Where(ch => !char.IsPunctuation(ch)).ToArray());
punctuationInsensitive = CollapseSpaces(punctuationInsensitive);
if (punctuationInsensitive.Length > 0 && punctuationInsensitive != normalized)
{
yield return punctuationInsensitive;
}
}
private static string NormalizeNameKey(string? value)
=> CollapseSpaces(value).ToUpperInvariant();
private static string CollapseSpaces(string? value)
{
var clean = Clean(value);
if (clean.Length == 0)
{
return string.Empty;
}
var builder = new StringBuilder(clean.Length);
var lastWasSpace = false;
foreach (var ch in clean)
{
if (char.IsWhiteSpace(ch))
{
if (!lastWasSpace)
{
builder.Append(' ');
lastWasSpace = true;
}
}
else
{
builder.Append(ch);
lastWasSpace = false;
}
}
return builder.ToString();
}
private static int? LookupLocationTypeId(ImportLookupIds lookupIds, string? type)
{

View File

@ -6,7 +6,11 @@ public sealed class PlotLineImportPackage
{
public string? PackageVersion { get; set; }
public string? Source { get; set; }
public string? CreatedBy { get; set; }
public string? CreatedDate { get; set; }
public string? Description { get; set; }
public ImportProjectDto Project { get; set; } = new();
public ImportAliasesDto Aliases { get; set; } = new();
public List<ImportCharacterDto> Characters { get; set; } = [];
public List<ImportLocationDto> Locations { get; set; } = [];
public List<ImportPlotLineDto> PlotLines { get; set; } = [];
@ -18,6 +22,12 @@ public sealed class PlotLineImportPackage
public Dictionary<string, object>? ExtensionData { get; set; }
}
public sealed class ImportAliasesDto
{
public Dictionary<string, string> Characters { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, string> Locations { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
public sealed class ImportProjectDto
{
public string Title { get; set; } = string.Empty;
@ -189,6 +199,9 @@ public sealed class ImportValidationResult
public bool IsValid => Errors.Count == 0;
public List<string> Errors { get; } = [];
public List<string> Warnings { get; } = [];
public List<string> AutoCorrections { get; } = [];
public List<string> SkippedItems { get; } = [];
public List<string> UnresolvedReferences { get; } = [];
}
public sealed class ImportPreview
@ -206,6 +219,14 @@ public sealed class ImportPreview
public IReadOnlyList<string> UnmatchedAssetOwnerNames { get; init; } = [];
public IReadOnlyList<string> UnmatchedAssetLocationNames { get; init; } = [];
public IReadOnlyList<string> UnresolvedSceneDependencyReferences { get; init; } = [];
public IReadOnlyList<string> AliasWarnings { get; init; } = [];
public IReadOnlyList<string> AutoCorrections { get; init; } = [];
public IReadOnlyList<string> SkippedItems { get; init; } = [];
public int ErrorCount => Validation.Errors.Count;
public int WarningCount => Validation.Warnings.Count;
public int AutoCorrectionCount => AutoCorrections.Count + Validation.AutoCorrections.Count;
public int SkippedItemCount => SkippedItems.Count + Validation.SkippedItems.Count;
public int UnresolvedReferenceCount => UnresolvedThreadEventReferences.Count + UnresolvedAssetReferences.Count + UnresolvedSceneDependencyReferences.Count;
public int BookCount => Package?.Books.Count ?? 0;
public int CharacterCount => Package?.Characters.Count ?? 0;
public int LocationCount => Package?.Locations.Count ?? 0;
@ -247,7 +268,7 @@ public sealed class ImportChapterPreview
public int SceneCount => Chapter.Scenes.Count;
}
public sealed class ImportResult
public sealed record ImportResult
{
public bool Succeeded { get; init; }
public int? ProjectID { get; init; }
@ -266,6 +287,13 @@ public sealed class ImportResult
public int AssetEventsCreated { get; init; }
public int AssetCustodyEventsCreated { get; init; }
public int SceneDependenciesCreated { get; init; }
public bool IsDryRun { get; init; }
public int ErrorCount { get; init; }
public int WarningCount { get; init; }
public int AutoCorrectionCount { get; init; }
public int SkippedItemCount { get; init; }
public int UnresolvedReferenceCount { get; init; }
public IReadOnlyList<string> AutoCorrections { get; init; } = [];
public IReadOnlyList<string> SkippedOptionalFields { get; init; } = [];
public IReadOnlyList<string> UnresolvedWarnings { get; init; } = [];
public string? TechnicalDetail { get; init; }

View File

@ -1,5 +1,6 @@
using System.Text;
using System.Text.Json;
using System.Diagnostics;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
@ -31,8 +32,15 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
public async Task<ImportIndexViewModel> PreviewAsync(ImportIndexViewModel model)
{
var timer = Stopwatch.StartNew();
model.JsonText = await ReadJsonTextAsync(model);
model.Preview = await BuildPreviewAsync(model.JsonText);
timer.Stop();
logger.LogInformation("JSON import preview completed in {ElapsedMilliseconds} ms with {ErrorCount} errors, {WarningCount} warnings, and {UnresolvedReferenceCount} unresolved references.",
timer.ElapsedMilliseconds,
model.Preview.ErrorCount,
model.Preview.WarningCount,
model.Preview.UnresolvedReferenceCount);
return model;
}
@ -78,9 +86,35 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
};
}
if (model.DryRun)
{
logger.LogInformation("JSON import dry run completed for project {ProjectTitle} with {WarningCount} warnings and {UnresolvedReferenceCount} unresolved references.",
preview.Package.Project.Title,
preview.WarningCount,
preview.UnresolvedReferenceCount);
return BuildDryRunResult(preview);
}
try
{
return await imports.ImportAsync(preview.Package, preview.HasDuplicateProjectTitle ? preview.DuplicateImportProjectTitle : null);
var timer = Stopwatch.StartNew();
var result = await imports.ImportAsync(preview.Package, preview.HasDuplicateProjectTitle ? preview.DuplicateImportProjectTitle : null);
timer.Stop();
logger.LogInformation("JSON import committed in {ElapsedMilliseconds} ms for project {ProjectName}. Created {SceneCount} scenes and skipped/unresolved count {SkippedCount}/{UnresolvedCount}.",
timer.ElapsedMilliseconds,
result.ProjectName,
result.ScenesCreated,
result.SkippedItemCount,
result.UnresolvedReferenceCount);
return result with
{
ErrorCount = preview.ErrorCount,
WarningCount = preview.WarningCount,
AutoCorrectionCount = preview.AutoCorrectionCount,
SkippedItemCount = preview.SkippedItemCount,
UnresolvedReferenceCount = preview.UnresolvedReferenceCount,
AutoCorrections = preview.AutoCorrections.Concat(preview.Validation.AutoCorrections).Distinct(StringComparer.OrdinalIgnoreCase).ToList()
};
}
catch (Exception ex)
{
@ -103,6 +137,18 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
builder.AppendLine($"Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC");
builder.AppendLine();
builder.AppendLine($"Project: {preview.Package?.Project.Title ?? "(unreadable)"}");
builder.AppendLine($"Source: {preview.Package?.Source ?? "(not supplied)"}");
builder.AppendLine($"Created by: {preview.Package?.CreatedBy ?? "(not supplied)"}");
builder.AppendLine($"Created date: {preview.Package?.CreatedDate ?? "(not supplied)"}");
builder.AppendLine($"Description: {preview.Package?.Description ?? "(not supplied)"}");
builder.AppendLine();
builder.AppendLine("Health");
builder.AppendLine($"Errors: {preview.ErrorCount}");
builder.AppendLine($"Warnings: {preview.WarningCount}");
builder.AppendLine($"Auto-corrections: {preview.AutoCorrectionCount}");
builder.AppendLine($"Skipped items: {preview.SkippedItemCount}");
builder.AppendLine($"Unresolved references: {preview.UnresolvedReferenceCount}");
builder.AppendLine();
builder.AppendLine($"Characters: {preview.CharacterCount}");
builder.AppendLine($"Locations: {preview.LocationCount}");
builder.AppendLine($"Plot lines: {preview.PlotLineCount}");
@ -120,6 +166,8 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
builder.AppendLine();
AppendReportSection(builder, "Errors", preview.Validation.Errors);
AppendReportSection(builder, "Warnings", preview.Validation.Warnings);
AppendReportSection(builder, "Auto-corrections", preview.AutoCorrections.Concat(preview.Validation.AutoCorrections).Distinct(StringComparer.OrdinalIgnoreCase).ToList());
AppendReportSection(builder, "Skipped items", preview.SkippedItems.Concat(preview.Validation.SkippedItems).Distinct(StringComparer.OrdinalIgnoreCase).ToList());
AppendReportSection(builder, "Unmatched POV names", preview.UnmatchedPovNames);
AppendReportSection(builder, "Unmatched location names", preview.UnmatchedLocationNames);
AppendReportSection(builder, "Unmatched scene character names", preview.UnmatchedSceneCharacterNames);
@ -128,6 +176,7 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
AppendReportSection(builder, "Unmatched asset owners", preview.UnmatchedAssetOwnerNames);
AppendReportSection(builder, "Unmatched asset locations", preview.UnmatchedAssetLocationNames);
AppendReportSection(builder, "Unresolved scene dependency references", preview.UnresolvedSceneDependencyReferences);
AppendReportSection(builder, "Alias warnings", preview.AliasWarnings);
return builder.ToString();
}
@ -161,6 +210,12 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
var validation = Validate(package);
AddMetadataWarnings(package, validation);
var aliasWarnings = FindAliasWarnings(package);
foreach (var warning in aliasWarnings)
{
validation.Warnings.Add(warning);
}
var duplicate = !string.IsNullOrWhiteSpace(package.Project.Title)
&& await imports.ProjectTitleExistsAsync(package.Project.Title.Trim());
@ -187,7 +242,51 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
UnresolvedAssetReferences = FindUnresolvedAssetReferences(package),
UnmatchedAssetOwnerNames = FindUnmatchedAssetOwnerNames(package),
UnmatchedAssetLocationNames = FindUnmatchedAssetLocationNames(package),
UnresolvedSceneDependencyReferences = FindUnresolvedSceneDependencyReferences(package)
UnresolvedSceneDependencyReferences = FindUnresolvedSceneDependencyReferences(package),
AliasWarnings = aliasWarnings,
AutoCorrections = FindAutoCorrections(package),
SkippedItems = FindSkippedItems(package)
};
}
private static ImportResult BuildDryRunResult(ImportPreview preview)
{
var unresolvedThreadEvents = preview.UnresolvedThreadEventReferences.Count;
var unresolvedAssetStates = preview.UnresolvedAssetReferences.Count(x => x.Contains("state", StringComparison.OrdinalIgnoreCase));
var unresolvedAssetEvents = preview.UnresolvedAssetReferences.Count(x => x.Contains("event", StringComparison.OrdinalIgnoreCase));
return new ImportResult
{
Succeeded = true,
IsDryRun = true,
ProjectID = null,
ProjectName = preview.Package?.Project.Title ?? "Dry run",
BooksCreated = preview.BookCount,
ChaptersCreated = preview.ChapterCount,
ScenesCreated = preview.SceneCount,
CharactersCreated = preview.CharacterCount,
LocationsCreated = preview.LocationCount,
SceneCharacterAppearancesCreated = preview.SceneCharacterAppearanceCount,
PlotLinesCreated = preview.PlotLineCount,
PlotThreadsCreated = preview.PlotThreadCount,
ThreadEventsCreated = Math.Max(0, preview.ThreadEventCount - unresolvedThreadEvents),
StoryAssetsCreated = preview.StoryAssetCount,
AssetStatesCreated = Math.Max(0, preview.AssetStateCount - unresolvedAssetStates),
AssetEventsCreated = Math.Max(0, preview.AssetEventCount - unresolvedAssetEvents),
AssetCustodyEventsCreated = preview.ResolvedAssetCustodyCount,
SceneDependenciesCreated = preview.ResolvedSceneDependencyCount,
ErrorCount = preview.ErrorCount,
WarningCount = preview.WarningCount,
AutoCorrectionCount = preview.AutoCorrectionCount,
SkippedItemCount = preview.SkippedItemCount,
UnresolvedReferenceCount = preview.UnresolvedReferenceCount,
AutoCorrections = preview.AutoCorrections.Concat(preview.Validation.AutoCorrections).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
SkippedOptionalFields = preview.SkippedItems.Concat(preview.Validation.SkippedItems).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
UnresolvedWarnings = preview.UnresolvedThreadEventReferences
.Concat(preview.UnresolvedAssetReferences)
.Concat(preview.UnresolvedSceneDependencyReferences)
.ToList(),
Message = "Dry run complete. No data was saved."
};
}
@ -238,6 +337,14 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
result.Errors.Add($"Book order {group.Key} is used more than once.");
}
foreach (var duplicateTitle in package.Books
.Where(book => !string.IsNullOrWhiteSpace(book.Title))
.GroupBy(book => NormalizeNameKey(book.Title), StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1))
{
result.Warnings.Add($"Book title \"{duplicateTitle.First().Title.Trim()}\" appears more than once.");
}
for (var bookIndex = 0; bookIndex < package.Books.Count; bookIndex++)
{
var book = package.Books[bookIndex];
@ -269,6 +376,14 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
result.Errors.Add($"{bookLabel} has duplicate chapter order {group.Key}.");
}
foreach (var duplicateTitle in book.Chapters
.Where(chapter => !string.IsNullOrWhiteSpace(chapter.Title))
.GroupBy(chapter => NormalizeNameKey(chapter.Title), StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1))
{
result.Warnings.Add($"{bookLabel} has duplicate chapter title \"{duplicateTitle.First().Title.Trim()}\".");
}
for (var chapterIndex = 0; chapterIndex < book.Chapters.Count; chapterIndex++)
{
var chapter = book.Chapters[chapterIndex];
@ -291,6 +406,10 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
{
result.Warnings.Add($"{bookLabel} / {chapterLabel} has no scenes.");
}
else if (chapter.Scenes.Count == 1)
{
result.Warnings.Add($"{bookLabel} / {chapterLabel} has only one scene. This may be intentional, but is worth checking in manuscript-derived data.");
}
ValidateLargeText(result, $"{bookLabel} / {chapterLabel} summary", chapter.Summary);
ValidateLargeText(result, $"{bookLabel} / {chapterLabel} notes", chapter.Notes);
@ -300,6 +419,14 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
result.Errors.Add($"{bookLabel} / {chapterLabel} has duplicate scene order {group.Key}.");
}
foreach (var duplicateTitle in chapter.Scenes
.Where(scene => !string.IsNullOrWhiteSpace(scene.Title))
.GroupBy(scene => NormalizeNameKey(scene.Title), StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1))
{
result.Warnings.Add($"{bookLabel} / {chapterLabel} has duplicate scene title \"{duplicateTitle.First().Title.Trim()}\".");
}
for (var sceneIndex = 0; sceneIndex < chapter.Scenes.Count; sceneIndex++)
{
var scene = chapter.Scenes[sceneIndex];
@ -318,6 +445,11 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
result.Errors.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} is missing a valid scene order.");
}
if (string.IsNullOrWhiteSpace(scene.Summary))
{
result.Warnings.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} has an empty scene summary.");
}
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.");
@ -330,15 +462,22 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
foreach (var duplicate in scene.SceneCharacters
.Where(x => !string.IsNullOrWhiteSpace(x.CharacterName))
.GroupBy(x => x.CharacterName.Trim(), StringComparer.OrdinalIgnoreCase)
.GroupBy(x => NormalizeNameKey(x.CharacterName), StringComparer.OrdinalIgnoreCase)
.Where(x => x.Count() > 1))
{
result.Errors.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} lists {duplicate.Key} more than once in sceneCharacters.");
var duplicateName = duplicate.First().CharacterName.Trim();
result.Warnings.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} lists {duplicateName} more than once in sceneCharacters. Duplicate appearances will be skipped.");
result.SkippedItems.Add($"Duplicate scene appearance skipped: {bookLabel} / {chapterLabel} / {sceneLabel} / {duplicateName}");
}
}
}
}
foreach (var book in package.Books.Where(book => book.SeriesOrder > 1 && !package.SceneDependencies.Any(dependency => dependency.ToScene?.BookOrder == book.SeriesOrder || dependency.FromScene?.BookOrder == book.SeriesOrder)))
{
result.Warnings.Add($"{book.Title} is in the middle of the series but has no scene dependencies.");
}
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.");
@ -463,6 +602,11 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
ValidateLargeText(result, $"{assetLabel} description", asset.Description);
ValidateLargeText(result, $"{assetLabel} notes", asset.Notes);
if (asset.States.Count == 0 && asset.Events.Count == 0)
{
result.Warnings.Add($"{assetLabel} has no states or events.");
}
foreach (var duplicate in asset.States
.Where(HasSceneOrderReference)
.GroupBy(AssetStateSceneKey)
@ -529,6 +673,15 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
private static void ValidatePlotLines(PlotLineImportPackage package, ImportValidationResult result)
{
foreach (var duplicateThread in package.PlotLines
.SelectMany(plotLine => plotLine.Threads)
.Where(thread => !string.IsNullOrWhiteSpace(thread.Name))
.GroupBy(thread => NormalizeNameKey(thread.Name), StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1))
{
result.Warnings.Add($"Plot thread name \"{duplicateThread.First().Name.Trim()}\" appears in more than one place.");
}
foreach (var group in package.PlotLines
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
@ -723,12 +876,12 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
private static IReadOnlyList<string> FindUnmatchedPovNames(PlotLineImportPackage package)
{
var characterNames = CharacterNames(package);
var characterLookup = BuildCharacterLookup(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))
.Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(characterLookup, ResolveAlias(name, package.Aliases?.Characters)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name)
.ToList()!;
@ -736,12 +889,12 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
private static IReadOnlyList<string> FindUnmatchedLocationNames(PlotLineImportPackage package)
{
var locationNames = LocationNames(package);
var locationLookup = BuildLocationLookup(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))
.Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(locationLookup, ResolveAlias(name, package.Aliases?.Locations)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name)
.ToList()!;
@ -749,13 +902,13 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
private static IReadOnlyList<string> FindUnmatchedSceneCharacterNames(PlotLineImportPackage package)
{
var characterNames = CharacterNames(package);
var characterLookup = BuildCharacterLookup(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))
.Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(characterLookup, ResolveAlias(name, package.Aliases?.Characters)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name)
.ToList()!;
@ -776,7 +929,7 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
Event = threadEvent
})))
.Where(x => !HasSceneOrderReference(x.Event) || !sceneKeys.Contains(EventSceneKey(x.Event)))
.Select(x => $"{x.PlotLineName} / {x.ThreadName} / {x.EventTitle} -> {EventSceneKey(x.Event)}")
.Select(x => $"{x.PlotLineName} / {x.ThreadName} / {x.EventTitle} -> {EventSceneKey(x.Event)}. {SceneReferenceDiagnostic(package, EventSceneKey(x.Event))}")
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x)
.ToList();
@ -796,7 +949,7 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
State = state
}))
.Where(x => !HasSceneOrderReference(x.State) || !sceneKeys.Contains(AssetStateSceneKey(x.State)))
.Select(x => $"{x.AssetName} / state / {x.Label} -> {AssetStateSceneKey(x.State)}");
.Select(x => $"{x.AssetName} / state / {x.Label} -> {AssetStateSceneKey(x.State)}. {SceneReferenceDiagnostic(package, AssetStateSceneKey(x.State))}");
var eventReferences = package.StoryAssets
.SelectMany(asset => asset.Events.Select((assetEvent, index) => new
@ -806,7 +959,7 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
Event = assetEvent
}))
.Where(x => !HasSceneOrderReference(x.Event) || !sceneKeys.Contains(AssetEventSceneKey(x.Event)))
.Select(x => $"{x.AssetName} / event / {x.Label} -> {AssetEventSceneKey(x.Event)}");
.Select(x => $"{x.AssetName} / event / {x.Label} -> {AssetEventSceneKey(x.Event)}. {SceneReferenceDiagnostic(package, AssetEventSceneKey(x.Event))}");
return stateReferences
.Concat(eventReferences)
@ -817,11 +970,11 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
private static IReadOnlyList<string> FindUnmatchedAssetOwnerNames(PlotLineImportPackage package)
{
var characterNames = CharacterNames(package);
var characterLookup = BuildCharacterLookup(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))
.Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(characterLookup, ResolveAlias(name, package.Aliases?.Characters)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name)
.ToList()!;
@ -829,11 +982,11 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
private static IReadOnlyList<string> FindUnmatchedAssetLocationNames(PlotLineImportPackage package)
{
var locationNames = LocationNames(package);
var locationLookup = BuildLocationLookup(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))
.Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(locationLookup, ResolveAlias(name, package.Aliases?.Locations)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name)
.ToList()!;
@ -860,7 +1013,7 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
}
})
.Where(x => !HasSceneOrderReference(x.Reference) || !sceneKeys.Contains(SceneReferenceKey(x.Reference!)))
.Select(x => $"{x.Label} -> {SceneReferenceKey(x.Reference)}")
.Select(x => $"{x.Label} -> {SceneReferenceKey(x.Reference)}. {SceneReferenceDiagnostic(package, SceneReferenceKey(x.Reference))}")
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x)
.ToList();
@ -895,6 +1048,46 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
private static string SceneDependencyKey(ImportSceneDependencyDto dependency)
=> $"{SceneReferenceKey(dependency.FromScene)} -> {SceneReferenceKey(dependency.ToScene)} / {CleanDependencyType(dependency.DependencyType)}";
private static string SceneReferenceDiagnostic(PlotLineImportPackage package, string requestedKey)
{
var scenes = BuildSceneReferenceInfos(package);
if (!TryParseSceneKey(requestedKey, out var requestedBook, out var requestedChapter, out var requestedScene))
{
return "Likely cause: missing bookOrder, chapterOrder, or sceneOrder.";
}
var closest = scenes
.OrderBy(scene => Math.Abs(scene.BookOrder - requestedBook) * 10_000 + Math.Abs(scene.ChapterOrder - requestedChapter) * 100 + Math.Abs(scene.SceneOrder - requestedScene))
.FirstOrDefault();
var cause = !scenes.Any(scene => scene.BookOrder == requestedBook)
? $"Book {requestedBook} was not found."
: !scenes.Any(scene => scene.BookOrder == requestedBook && scene.ChapterOrder == requestedChapter)
? $"Book {requestedBook} chapter {requestedChapter} was not found."
: $"Book {requestedBook} chapter {requestedChapter} scene {requestedScene} was not found.";
return closest is null
? $"Likely cause: {cause}"
: $"Closest scene: Book {closest.BookOrder} Chapter {closest.ChapterOrder} Scene {closest.SceneOrder} ({closest.SceneTitle}). Likely cause: {cause}";
}
private static IReadOnlyList<ImportSceneReferenceInfo> BuildSceneReferenceInfos(PlotLineImportPackage package)
=> package.Books
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => new ImportSceneReferenceInfo(book.SeriesOrder, chapter.Order, scene.Order, scene.Title))))
.ToList();
private static bool TryParseSceneKey(string key, out int bookOrder, out int chapterOrder, out int sceneOrder)
{
bookOrder = 0;
chapterOrder = 0;
sceneOrder = 0;
var parts = key.Split('/');
return parts.Length == 3
&& int.TryParse(parts[0], out bookOrder)
&& int.TryParse(parts[1], out chapterOrder)
&& int.TryParse(parts[2], out sceneOrder);
}
private static string CleanDependencyType(string? dependencyType)
=> string.IsNullOrWhiteSpace(dependencyType) ? "Must Occur Before" : dependencyType.Trim();
@ -913,17 +1106,206 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
|| (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 IReadOnlyList<string> FindAliasWarnings(PlotLineImportPackage package)
{
var warnings = new List<string>();
var characterNames = BuildNameLookup(package.Characters.SelectMany(character => new[] { character.Name, character.DisplayName }));
var locationNames = BuildNameLookup(package.Locations.SelectMany(location => new[] { location.Name, location.DisplayName }));
private static HashSet<string> LocationNames(PlotLineImportPackage package)
=> package.Locations
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
.Select(x => x.Name.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var alias in package.Aliases?.Characters ?? [])
{
if (!ContainsLookupKey(characterNames, alias.Value))
{
warnings.Add($"Character alias \"{alias.Key}\" points to \"{alias.Value}\", but that target is not a top-level character.");
}
}
foreach (var alias in package.Aliases?.Locations ?? [])
{
if (!ContainsLookupKey(locationNames, alias.Value))
{
warnings.Add($"Location alias \"{alias.Key}\" points to \"{alias.Value}\", but that target is not a top-level location.");
}
}
return warnings;
}
private static IReadOnlyList<string> FindAutoCorrections(PlotLineImportPackage package)
{
var corrections = new List<string>();
AddAliasCorrections(corrections, "Character alias", package.Aliases?.Characters);
AddAliasCorrections(corrections, "Location alias", package.Aliases?.Locations);
foreach (var value in AllImportedNames(package).Where(value => !string.IsNullOrWhiteSpace(value)))
{
var collapsed = CollapseSpaces(value!);
if (!string.Equals(value!.Trim(), collapsed, StringComparison.Ordinal))
{
corrections.Add($"Repeated spaces normalized: \"{value}\" -> \"{collapsed}\".");
}
}
return corrections.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList();
}
private static IReadOnlyList<string> FindSkippedItems(PlotLineImportPackage package)
{
var skipped = new List<string>();
foreach (var duplicate in package.Books
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.SelectMany(scene => scene.SceneCharacters.Select(sceneCharacter => new
{
Book = book.Title,
Chapter = chapter.Title,
Scene = scene.Title,
sceneCharacter.CharacterName
}))))
.Where(x => !string.IsNullOrWhiteSpace(x.CharacterName))
.GroupBy(x => $"{NormalizeNameKey(x.Book)}/{NormalizeNameKey(x.Chapter)}/{NormalizeNameKey(x.Scene)}/{NormalizeNameKey(x.CharacterName)}", StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1))
{
var item = duplicate.First();
skipped.Add($"Duplicate scene appearance skipped: {item.Book} / {item.Chapter} / {item.Scene} / {item.CharacterName.Trim()}");
}
foreach (var duplicate in package.SceneDependencies
.Where(dependency => HasSceneOrderReference(dependency.FromScene) && HasSceneOrderReference(dependency.ToScene))
.GroupBy(SceneDependencyKey, StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1))
{
skipped.Add($"Duplicate scene dependency skipped: {duplicate.Key}");
}
return skipped.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList();
}
private static void AddAliasCorrections(List<string> corrections, string label, Dictionary<string, string>? aliases)
{
foreach (var alias in aliases ?? [])
{
corrections.Add($"{label} applied: \"{alias.Key}\" -> \"{alias.Value}\".");
}
}
private static Dictionary<string, bool> BuildCharacterLookup(PlotLineImportPackage package)
=> BuildNameLookup(package.Characters.SelectMany(character => new[] { character.Name, character.DisplayName }));
private static Dictionary<string, bool> BuildLocationLookup(PlotLineImportPackage package)
=> BuildNameLookup(package.Locations.SelectMany(location => new[] { location.Name, location.DisplayName }));
private static Dictionary<string, bool> BuildNameLookup(IEnumerable<string?> names)
{
var lookup = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
var candidateOwners = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in names.Where(name => !string.IsNullOrWhiteSpace(name)))
{
foreach (var key in CandidateNameKeys(name))
{
if (candidateOwners.TryGetValue(key, out var existing) && !string.Equals(existing, name, StringComparison.OrdinalIgnoreCase))
{
lookup[key] = false;
}
else
{
candidateOwners[key] = name!;
lookup[key] = true;
}
}
}
return lookup;
}
private static bool ContainsLookupKey(Dictionary<string, bool> lookup, string? name)
=> IsKnownName(lookup, name);
private static bool IsKnownName(Dictionary<string, bool> lookup, string? name)
=> CandidateNameKeys(name).Any(key => lookup.TryGetValue(key, out var isSafe) && isSafe);
private static string ResolveAlias(string? name, Dictionary<string, string>? aliases)
{
if (string.IsNullOrWhiteSpace(name))
{
return string.Empty;
}
var clean = CollapseSpaces(name);
if (aliases is null || aliases.Count == 0)
{
return clean;
}
foreach (var key in CandidateNameKeys(clean))
{
var match = aliases.FirstOrDefault(alias => CandidateNameKeys(alias.Key).Contains(key, StringComparer.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(match.Value))
{
return CollapseSpaces(match.Value);
}
}
return clean;
}
private static IEnumerable<string?> AllImportedNames(PlotLineImportPackage package)
=> package.Characters.SelectMany(character => new[] { character.Name, character.DisplayName, character.ShortName })
.Concat(package.Locations.SelectMany(location => new[] { location.Name, location.DisplayName, location.ParentLocationName }))
.Concat(package.Books.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.SelectMany(scene => new[] { scene.PovCharacterName, scene.LocationName }))))
.Concat(package.Books.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.SelectMany(scene => scene.SceneCharacters.Select(sceneCharacter => sceneCharacter.CharacterName)))))
.Concat(package.StoryAssets.SelectMany(asset => new[] { asset.InitialOwnerCharacterName, asset.InitialLocationName }));
private static IEnumerable<string> CandidateNameKeys(string? name)
{
var normalized = NormalizeNameKey(name);
if (normalized.Length == 0)
{
yield break;
}
yield return normalized;
var punctuationInsensitive = new string(normalized.Where(ch => !char.IsPunctuation(ch)).ToArray());
punctuationInsensitive = CollapseSpaces(punctuationInsensitive);
if (punctuationInsensitive.Length > 0 && punctuationInsensitive != normalized)
{
yield return punctuationInsensitive;
}
}
private static string NormalizeNameKey(string? value)
=> CollapseSpaces(value).ToUpperInvariant();
private static string CollapseSpaces(string? value)
{
var clean = (value ?? string.Empty).Trim();
if (clean.Length == 0)
{
return string.Empty;
}
var builder = new StringBuilder(clean.Length);
var lastWasSpace = false;
foreach (var ch in clean)
{
if (char.IsWhiteSpace(ch))
{
if (!lastWasSpace)
{
builder.Append(' ');
lastWasSpace = true;
}
}
else
{
builder.Append(ch);
lastWasSpace = false;
}
}
return builder.ToString();
}
private static void ValidateLargeText(ImportValidationResult result, string fieldName, string? value)
{
@ -967,4 +1349,6 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
return model.JsonText ?? string.Empty;
}
private sealed record ImportSceneReferenceInfo(int BookOrder, int ChapterOrder, int SceneOrder, string SceneTitle);
}

View File

@ -10,6 +10,7 @@ public sealed class ImportIndexViewModel
public bool AllowDuplicateProjectTitle { get; set; }
public bool ConfirmImport { get; set; }
public bool AcknowledgeWarnings { get; set; }
public bool DryRun { get; set; }
public ImportPreview? Preview { get; set; }
public string? StatusMessage { get; set; }
public string? TechnicalDetail { get; set; }
@ -21,12 +22,26 @@ public sealed class ImportIndexViewModel
public static string SampleJson => """
{
"packageVersion": "1.0",
"source": "JSON Import Phase 1F sample",
"source": "JSON Import Phase 1G sample",
"createdBy": "PlotLine sample generator",
"createdDate": "2026-06-02",
"description": "Phase 1G sample with aliases, metadata, dependencies, and a few validation warnings.",
"project": {
"title": "The Glass Meridian",
"description": "A placeholder two-book fantasy outline.",
"notes": "Imported sample data for testing."
},
"aliases": {
"characters": {
"mara vale": "Mara",
"Ilen Or": "Ilen"
},
"locations": {
"service staircase": "Service stair",
"Tidal Causeway": "Tidal causeway",
"old stairs": "Missing Stairwell"
}
},
"characters": [
{ "name": "Mara", "displayName": "Mara Vale", "shortName": "Mara", "role": "Protagonist", "importance": 10, "description": "Courier drawn into the hidden city.", "notes": "Primary POV for book one." },
{ "name": "Ilen", "displayName": "Ilen Or", "shortName": "Ilen", "role": "Guide", "importance": 8, "description": "Watchman with partial knowledge of the old routes.", "notes": "Carries secrets into book two." },

View File

@ -8,7 +8,7 @@
<div>
<p class="eyebrow">Tools</p>
<h1>JSON Import</h1>
<p class="lead-text">Paste or upload a Phase 1F package, preview it, confirm the summary, then import a new project.</p>
<p class="lead-text">Paste or upload a Phase 1G package, preview it, dry-run if needed, then import a new project.</p>
</div>
<div class="button-row">
<a class="btn btn-outline-primary" asp-action="Sample">Download sample JSON</a>
@ -65,7 +65,11 @@
</div>
<div>
<h3>Optional</h3>
<p><code>packageVersion</code>, <code>source</code>, top-level <code>characters</code>, top-level <code>locations</code>, top-level <code>plotLines</code>, top-level <code>storyAssets</code>, top-level <code>sceneDependencies</code>, plot line <code>threads</code>, thread <code>events</code>, asset <code>states</code>, asset <code>events</code>, initial asset owner/location mapping, POV/location mapping, date/time text, purpose/outcome text, and <code>sceneCharacters</code>.</p>
<p><code>packageVersion</code>, <code>source</code>, <code>createdBy</code>, <code>createdDate</code>, <code>description</code>, top-level <code>aliases</code>, top-level <code>characters</code>, top-level <code>locations</code>, top-level <code>plotLines</code>, top-level <code>storyAssets</code>, top-level <code>sceneDependencies</code>, plot line <code>threads</code>, thread <code>events</code>, asset <code>states</code>, asset <code>events</code>, initial asset owner/location mapping, POV/location mapping, date/time text, purpose/outcome text, and <code>sceneCharacters</code>.</p>
</div>
<div>
<h3>Dry run and aliases</h3>
<p>Dry run performs validation and mapping diagnostics without saving data. <code>aliases.characters</code> and <code>aliases.locations</code> map imported names to top-level names before matching. Matching is case-insensitive, trims whitespace, normalizes repeated spaces, and uses punctuation-insensitive matching only when safe.</p>
</div>
<div>
<h3>Scene links</h3>
@ -73,7 +77,7 @@
</div>
<div>
<h3>Not imported yet</h3>
<p>No AI extraction, Word/text parsing, manuscript parsing, metrics import, continuity warning import, relationship graphs, merge/update, or export logic.</p>
<p>No AI extraction, Word/text parsing, manuscript parsing, metrics import, continuity warning import, relationship graphs, merge/update, or export logic. This importer is ready for manuscript-derived JSON, and unknown fields are ignored safely.</p>
</div>
</div>
</details>
@ -102,9 +106,37 @@
<span><strong>@preview.ResolvedAssetEventCount</strong> asset events</span>
<span><strong>@preview.ResolvedAssetCustodyCount</strong> custody links</span>
<span><strong>@preview.ResolvedSceneDependencyCount</strong> dependencies</span>
<span><strong>@preview.ErrorCount</strong> errors</span>
<span><strong>@preview.WarningCount</strong> warnings</span>
<span><strong>@preview.UnresolvedReferenceCount</strong> unresolved</span>
<span><strong>@preview.AutoCorrectionCount</strong> corrections</span>
<span><strong>@preview.SkippedItemCount</strong> skipped</span>
</div>
</div>
<div class="alert alert-light import-message-list">
<strong>Import health</strong>
<div class="import-counts mt-2">
<span><strong>@preview.ErrorCount</strong> errors</span>
<span><strong>@preview.WarningCount</strong> warnings</span>
<span><strong>@preview.AutoCorrectionCount</strong> auto-corrections</span>
<span><strong>@preview.SkippedItemCount</strong> skipped items</span>
<span><strong>@preview.UnresolvedReferenceCount</strong> unresolved references</span>
</div>
@if (preview.Package is not null && (!string.IsNullOrWhiteSpace(preview.Package.Source) || !string.IsNullOrWhiteSpace(preview.Package.CreatedBy) || !string.IsNullOrWhiteSpace(preview.Package.CreatedDate) || !string.IsNullOrWhiteSpace(preview.Package.Description)))
{
<p class="muted mt-2 mb-0">
Source: @(preview.Package.Source ?? "Not supplied") /
Created by: @(preview.Package.CreatedBy ?? "Not supplied") /
Created date: @(preview.Package.CreatedDate ?? "Not supplied")
@if (!string.IsNullOrWhiteSpace(preview.Package.Description))
{
<span>/ @preview.Package.Description</span>
}
</p>
}
</div>
<div class="button-row mb-3">
<form asp-action="ValidationReport" method="post">
<input asp-for="JsonText" type="hidden" />
@ -138,6 +170,25 @@
</div>
}
@if (preview.AutoCorrections.Any() || preview.Validation.AutoCorrections.Any() || preview.SkippedItems.Any() || preview.Validation.SkippedItems.Any() || preview.AliasWarnings.Any())
{
<details class="import-mapping-summary" open>
<summary>Health details</summary>
@if (preview.AutoCorrections.Any() || preview.Validation.AutoCorrections.Any())
{
<span>Auto-corrections: @string.Join("; ", preview.AutoCorrections.Concat(preview.Validation.AutoCorrections).Take(12))</span>
}
@if (preview.SkippedItems.Any() || preview.Validation.SkippedItems.Any())
{
<span>Skipped items: @string.Join("; ", preview.SkippedItems.Concat(preview.Validation.SkippedItems).Take(12))</span>
}
@if (preview.AliasWarnings.Any())
{
<span>Alias warnings: @string.Join("; ", preview.AliasWarnings.Take(12))</span>
}
</details>
}
@if (preview.Package is not null)
{
@if (preview.UnmatchedPovNames.Any() || preview.UnmatchedLocationNames.Any() || preview.UnmatchedSceneCharacterNames.Any() || preview.UnresolvedThreadEventReferences.Any() || preview.UnresolvedAssetReferences.Any() || preview.UnmatchedAssetOwnerNames.Any() || preview.UnmatchedAssetLocationNames.Any() || preview.UnresolvedSceneDependencyReferences.Any())
@ -182,7 +233,7 @@
@foreach (var bookPreview in preview.BookPreviews)
{
var book = bookPreview.Book;
<details class="import-tree-book" open>
<details class="import-tree-book" @(preview.BookCount <= 4 ? "open" : "")>
<summary>
<strong>Book @book.SeriesOrder: @(string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}")</strong>
<span>@bookPreview.ChapterCount chapters / @bookPreview.SceneCount scenes</span>
@ -317,7 +368,11 @@
<input asp-for="ConfirmImport" class="form-check-input" />
<label asp-for="ConfirmImport" class="form-check-label">I understand this will create new records in PlotLine.</label>
</div>
<button class="btn btn-primary mt-3" type="submit" disabled="@(!preview.Validation.IsValid)">Import project</button>
<div class="form-check import-duplicate-confirm mt-2">
<input asp-for="DryRun" class="form-check-input" />
<label asp-for="DryRun" class="form-check-label">Dry run only. Validate and simulate counts without saving data.</label>
</div>
<button class="btn btn-primary mt-3" type="submit" disabled="@(!preview.Validation.IsValid)">Import or dry run</button>
</form>
</section>
}

View File

@ -1,16 +1,19 @@
@model ImportResult
@{
ViewData["Title"] = "Import Complete";
ViewData["Title"] = Model.IsDryRun ? "Dry Run Complete" : "Import Complete";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Import result</p>
<h1>Import complete</h1>
<p class="lead-text">@Model.ProjectName was created successfully.</p>
<h1>@(Model.IsDryRun ? "Dry run complete" : "Import complete")</h1>
<p class="lead-text">@(Model.IsDryRun ? $"{Model.ProjectName} was validated and simulated. No data was saved." : $"{Model.ProjectName} was created successfully.")</p>
</div>
<div class="button-row">
<a class="btn btn-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open imported project</a>
@if (!Model.IsDryRun && Model.ProjectID.HasValue)
{
<a class="btn btn-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open imported project</a>
}
<a class="btn btn-outline-secondary" asp-action="Index">Import another JSON package</a>
</div>
</div>
@ -31,8 +34,31 @@
<span><strong>@Model.AssetEventsCreated</strong> asset events created</span>
<span><strong>@Model.AssetCustodyEventsCreated</strong> custody records created</span>
<span><strong>@Model.SceneDependenciesCreated</strong> scene dependencies created</span>
<span><strong>@Model.ErrorCount</strong> errors</span>
<span><strong>@Model.WarningCount</strong> warnings</span>
<span><strong>@Model.AutoCorrectionCount</strong> corrections</span>
<span><strong>@Model.SkippedItemCount</strong> skipped</span>
<span><strong>@Model.UnresolvedReferenceCount</strong> unresolved</span>
</div>
<div class="alert @(Model.IsDryRun ? "alert-info" : "alert-light") mt-3">
<strong>@(Model.IsDryRun ? "Dry run health" : "Import health")</strong>
<p class="mb-0 mt-2">Errors: @Model.ErrorCount / Warnings: @Model.WarningCount / Auto-corrections: @Model.AutoCorrectionCount / Skipped items: @Model.SkippedItemCount / Unresolved references: @Model.UnresolvedReferenceCount</p>
</div>
@if (Model.AutoCorrections.Any())
{
<div class="alert alert-info mt-3">
<strong>Auto-corrections</strong>
<ul class="mb-0 mt-2">
@foreach (var item in Model.AutoCorrections.Take(20))
{
<li>@item</li>
}
</ul>
</div>
}
@if (Model.SkippedOptionalFields.Any())
{
<div class="alert alert-info mt-3">
@ -59,5 +85,5 @@
</div>
}
<p class="muted mt-3 mb-0">The import was transactional, so all listed records were created together.</p>
<p class="muted mt-3 mb-0">@(Model.IsDryRun ? "Dry run wrote nothing to the database." : "The import was transactional, so all listed records were created together.")</p>
</section>