Importer Phase 1G
This commit is contained in:
parent
b684146248
commit
ef5688bb88
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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." },
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user