Importer Phase 1D

This commit is contained in:
Nick Beckley 2026-06-01 21:41:06 +01:00
parent a39b5cb7d8
commit ed341587de
6 changed files with 584 additions and 10 deletions

View File

@ -13,7 +13,7 @@ public interface IImportRepository
public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository
{
private const string ImportMarker = "Imported via JSON Import Phase 1D";
private const string ImportMarker = "Imported via JSON Import Phase 1E";
public async Task<bool> ProjectTitleExistsAsync(string projectTitle)
{
@ -46,6 +46,10 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
var plotLinesCreated = 0;
var plotThreadsCreated = 0;
var threadEventsCreated = 0;
var storyAssetsCreated = 0;
var assetStatesCreated = 0;
var assetEventsCreated = 0;
var assetCustodyEventsCreated = 0;
var skippedFields = GetSkippedOptionalFields(package);
var unresolvedWarnings = GetUnresolvedWarnings(package);
var sceneMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
@ -355,6 +359,138 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
}
}
var firstSceneId = sceneMap.Values.FirstOrDefault();
foreach (var storyAsset in package.StoryAssets)
{
if (string.IsNullOrWhiteSpace(storyAsset.Name))
{
continue;
}
var initialLocationId = ResolveId(locationMap, storyAsset.InitialLocationName);
var initialOwnerCharacterId = ResolveId(characterMap, storyAsset.InitialOwnerCharacterName);
var storyAssetId = await connection.QuerySingleAsync<int>(
"dbo.StoryAsset_Save",
new
{
StoryAssetID = 0,
ProjectID = projectId,
AssetName = Clean(DisplayName(storyAsset.DisplayName, storyAsset.Name)),
AssetKindID = ResolveAssetKindId(lookupIds, storyAsset.Type),
Description = CombineText(
storyAsset.Description,
MetadataLine("Type", storyAsset.Type),
initialOwnerCharacterId.HasValue ? null : MetadataLine("Initial owner", storyAsset.InitialOwnerCharacterName),
initialLocationId.HasValue ? null : MetadataLine("Initial location", storyAsset.InitialLocationName),
storyAsset.Notes,
ImportMarker),
Importance = ClampImportance(storyAsset.Importance),
CurrentStateID = ResolveAssetStateId(lookupIds, storyAsset.States.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.StateName))?.StateName),
CurrentLocationID = initialLocationId,
IsResolved = false
},
transaction,
commandType: CommandType.StoredProcedure);
storyAssetsCreated++;
if (initialOwnerCharacterId.HasValue && firstSceneId > 0)
{
await connection.QuerySingleAsync<int>(
"dbo.AssetCustodyEvent_Save",
new
{
AssetCustodyEventID = 0,
StoryAssetID = storyAssetId,
SceneID = firstSceneId,
AssetCustodyEventTypeID = ResolveAssetCustodyEventTypeId(lookupIds, "Has"),
Description = CombineText("Initial owner imported from JSON.", ImportMarker),
CustodianNames = storyAsset.InitialOwnerCharacterName,
CustodyRoleID = ResolveCustodyRoleId(lookupIds, "Has Custody")
},
transaction,
commandType: CommandType.StoredProcedure);
assetCustodyEventsCreated++;
}
var importedAssetStateSceneIds = new HashSet<int>();
foreach (var state in storyAsset.States)
{
if (!TryResolveAssetStateScene(sceneMap, state, out var sceneId) || importedAssetStateSceneIds.Contains(sceneId))
{
continue;
}
await connection.QuerySingleAsync<int>(
"dbo.AssetEvent_Save",
new
{
AssetEventID = 0,
StoryAssetID = storyAssetId,
SceneID = sceneId,
AssetEventTypeID = ResolveAssetEventTypeId(lookupIds, "State Changed"),
FromStateID = (int?)null,
ToStateID = ResolveAssetStateId(lookupIds, state.StateName),
EventTitle = Truncate(Clean(state.StateName).Length == 0 ? "Imported asset state" : state.StateName, 200),
EventDescription = CombineText(state.Description, state.Notes, ImportMarker)
},
transaction,
commandType: CommandType.StoredProcedure);
importedAssetStateSceneIds.Add(sceneId);
assetStatesCreated++;
}
var importedAssetEventSceneIds = new HashSet<int>();
foreach (var assetEvent in storyAsset.Events)
{
if (!TryResolveAssetEventScene(sceneMap, assetEvent, out var sceneId) || importedAssetEventSceneIds.Contains(sceneId))
{
continue;
}
var eventLocationId = ResolveId(locationMap, assetEvent.LocationName);
if (eventLocationId.HasValue)
{
await connection.QuerySingleAsync<int>(
"dbo.SceneAssetLocation_Save",
new
{
SceneAssetLocationID = 0,
SceneID = sceneId,
StoryAssetID = storyAssetId,
LocationID = eventLocationId.Value,
Description = CombineText(assetEvent.Summary, assetEvent.Notes, ImportMarker),
UpdateCurrentLocation = true
},
transaction,
commandType: CommandType.StoredProcedure);
}
await connection.QuerySingleAsync<int>(
"dbo.AssetEvent_Save",
new
{
AssetEventID = 0,
StoryAssetID = storyAssetId,
SceneID = sceneId,
AssetEventTypeID = ResolveAssetEventTypeId(lookupIds, assetEvent.EventType),
FromStateID = (int?)null,
ToStateID = (int?)null,
EventTitle = Truncate(Clean(assetEvent.Title).Length == 0 ? Clean(assetEvent.Summary) : assetEvent.Title, 200) ?? "Imported asset event",
EventDescription = CombineText(
assetEvent.Summary,
MetadataLine("Event type", assetEvent.EventType),
MetadataLine("Character", assetEvent.CharacterName),
eventLocationId.HasValue ? null : MetadataLine("Location", assetEvent.LocationName),
assetEvent.Notes,
ImportMarker)
},
transaction,
commandType: CommandType.StoredProcedure);
importedAssetEventSceneIds.Add(sceneId);
assetEventsCreated++;
}
}
transaction.Commit();
return new ImportResult
{
@ -370,6 +506,10 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
PlotLinesCreated = plotLinesCreated,
PlotThreadsCreated = plotThreadsCreated,
ThreadEventsCreated = threadEventsCreated,
StoryAssetsCreated = storyAssetsCreated,
AssetStatesCreated = assetStatesCreated,
AssetEventsCreated = assetEventsCreated,
AssetCustodyEventsCreated = assetCustodyEventsCreated,
SkippedOptionalFields = skippedFields,
UnresolvedWarnings = unresolvedWarnings,
Message = "Import complete."
@ -415,6 +555,21 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
var threadEventTypes = (await connection.QueryAsync<ImportLookupRow>(
"SELECT ThreadEventTypeID AS LookupID, TypeName FROM dbo.ThreadEventTypes WHERE IsActive = 1;",
transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
var assetKinds = (await connection.QueryAsync<ImportLookupRow>(
"SELECT AssetKindID AS LookupID, KindName AS TypeName FROM dbo.AssetKinds WHERE IsActive = 1;",
transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
var assetStates = (await connection.QueryAsync<ImportLookupRow>(
"SELECT AssetStateID AS LookupID, StateName AS TypeName FROM dbo.AssetStates WHERE IsActive = 1;",
transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
var assetEventTypes = (await connection.QueryAsync<ImportLookupRow>(
"SELECT AssetEventTypeID AS LookupID, TypeName FROM dbo.AssetEventTypes WHERE IsActive = 1;",
transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
var assetCustodyEventTypes = (await connection.QueryAsync<ImportLookupRow>(
"SELECT AssetCustodyEventTypeID AS LookupID, TypeName FROM dbo.AssetCustodyEventTypes WHERE IsActive = 1;",
transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
var custodyRoles = (await connection.QueryAsync<ImportLookupRow>(
"SELECT CustodyRoleID AS LookupID, RoleName AS TypeName FROM dbo.CustodyRoles WHERE IsActive = 1;",
transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
if (!revisionStatusId.HasValue || !timeModeId.HasValue || !timeConfidenceId.HasValue)
{
@ -431,7 +586,12 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
plotLineTypes,
threadTypes,
threadStatuses,
threadEventTypes);
threadEventTypes,
assetKinds,
assetStates,
assetEventTypes,
assetCustodyEventTypes,
custodyRoles);
}
private static string FormatBookTitle(ImportBookDto book)
@ -475,6 +635,11 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
skipped.Add("Thread event impact and importance were stored in event descriptions.");
}
if (package.StoryAssets.Any(asset => asset.Events.Any(assetEvent => !string.IsNullOrWhiteSpace(assetEvent.CharacterName))))
{
skipped.Add("Asset event character names were stored in event descriptions.");
}
return skipped;
}
@ -522,6 +687,48 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
warnings.Add($"Unresolved thread event scene reference: {reference}");
}
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))
.Distinct(StringComparer.OrdinalIgnoreCase))
{
warnings.Add($"Unmatched asset character: {name}");
}
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))
.Distinct(StringComparer.OrdinalIgnoreCase))
{
warnings.Add($"Unmatched asset location: {name}");
}
foreach (var reference in package.StoryAssets.SelectMany(asset => asset.States.Select((state, index) => new
{
AssetName = DisplayName(asset.DisplayName, asset.Name),
Label = string.IsNullOrWhiteSpace(state.StateName) ? $"State {index + 1}" : state.StateName.Trim(),
State = state
}))
.Where(x => !x.State.BookOrder.HasValue || !x.State.ChapterOrder.HasValue || !x.State.SceneOrder.HasValue || !sceneKeys.Contains(SceneKey(x.State.BookOrder.GetValueOrDefault(), x.State.ChapterOrder.GetValueOrDefault(), x.State.SceneOrder.GetValueOrDefault())))
.Select(x => $"{x.AssetName} / state / {x.Label}")
.Distinct(StringComparer.OrdinalIgnoreCase))
{
warnings.Add($"Unresolved asset state scene reference: {reference}");
}
foreach (var reference in package.StoryAssets.SelectMany(asset => asset.Events.Select((assetEvent, index) => new
{
AssetName = DisplayName(asset.DisplayName, asset.Name),
Label = string.IsNullOrWhiteSpace(assetEvent.Title) ? $"Event {index + 1}" : assetEvent.Title.Trim(),
Event = assetEvent
}))
.Where(x => !x.Event.BookOrder.HasValue || !x.Event.ChapterOrder.HasValue || !x.Event.SceneOrder.HasValue || !sceneKeys.Contains(SceneKey(x.Event.BookOrder.GetValueOrDefault(), x.Event.ChapterOrder.GetValueOrDefault(), x.Event.SceneOrder.GetValueOrDefault())))
.Select(x => $"{x.AssetName} / event / {x.Label}")
.Distinct(StringComparer.OrdinalIgnoreCase))
{
warnings.Add($"Unresolved asset event scene reference: {reference}");
}
return warnings;
}
@ -656,6 +863,62 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
: lookupIds.ThreadEventTypeIds.Values.First();
}
private static int ResolveAssetKindId(ImportLookupIds lookupIds, string? type)
{
if (!string.IsNullOrWhiteSpace(type) && lookupIds.AssetKindIds.TryGetValue(type.Trim(), out var id))
{
return id;
}
return lookupIds.AssetKindIds.TryGetValue("Object", out var objectId)
? objectId
: lookupIds.AssetKindIds.Values.First();
}
private static int? ResolveAssetStateId(ImportLookupIds lookupIds, string? state)
{
if (!string.IsNullOrWhiteSpace(state) && lookupIds.AssetStateIds.TryGetValue(state.Trim(), out var id))
{
return id;
}
return null;
}
private static int ResolveAssetEventTypeId(ImportLookupIds lookupIds, string? eventType)
{
if (!string.IsNullOrWhiteSpace(eventType) && lookupIds.AssetEventTypeIds.TryGetValue(eventType.Trim(), out var id))
{
return id;
}
return lookupIds.AssetEventTypeIds.TryGetValue("Mentioned", out var mentionedId)
? mentionedId
: lookupIds.AssetEventTypeIds.Values.First();
}
private static int ResolveAssetCustodyEventTypeId(ImportLookupIds lookupIds, string? eventType)
{
if (!string.IsNullOrWhiteSpace(eventType) && lookupIds.AssetCustodyEventTypeIds.TryGetValue(eventType.Trim(), out var id))
{
return id;
}
return lookupIds.AssetCustodyEventTypesFallback();
}
private static int ResolveCustodyRoleId(ImportLookupIds lookupIds, string? role)
{
if (!string.IsNullOrWhiteSpace(role) && lookupIds.CustodyRoleIds.TryGetValue(role.Trim(), out var id))
{
return id;
}
return lookupIds.CustodyRoleIds.TryGetValue("Holder", out var holderId)
? holderId
: lookupIds.CustodyRoleIds.Values.First();
}
private static int ClampImportance(int? importance)
=> Math.Clamp(importance ?? 5, 1, 10);
@ -670,6 +933,28 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
return false;
}
private static bool TryResolveAssetStateScene(IReadOnlyDictionary<string, int> sceneMap, ImportAssetStateDto state, out int sceneId)
{
if (state.BookOrder.HasValue && state.ChapterOrder.HasValue && state.SceneOrder.HasValue)
{
return sceneMap.TryGetValue(SceneKey(state.BookOrder.Value, state.ChapterOrder.Value, state.SceneOrder.Value), out sceneId);
}
sceneId = 0;
return false;
}
private static bool TryResolveAssetEventScene(IReadOnlyDictionary<string, int> sceneMap, ImportAssetEventDto assetEvent, out int sceneId)
{
if (assetEvent.BookOrder.HasValue && assetEvent.ChapterOrder.HasValue && assetEvent.SceneOrder.HasValue)
{
return sceneMap.TryGetValue(SceneKey(assetEvent.BookOrder.Value, assetEvent.ChapterOrder.Value, assetEvent.SceneOrder.Value), out sceneId);
}
sceneId = 0;
return false;
}
private static string SceneKey(int bookOrder, int chapterOrder, int sceneOrder)
=> $"{bookOrder}/{chapterOrder}/{sceneOrder}";
@ -724,7 +1009,18 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
IReadOnlyDictionary<string, int> PlotLineTypeIds,
IReadOnlyDictionary<string, int> ThreadTypeIds,
IReadOnlyDictionary<string, int> ThreadStatusIds,
IReadOnlyDictionary<string, int> ThreadEventTypeIds);
IReadOnlyDictionary<string, int> ThreadEventTypeIds,
IReadOnlyDictionary<string, int> AssetKindIds,
IReadOnlyDictionary<string, int> AssetStateIds,
IReadOnlyDictionary<string, int> AssetEventTypeIds,
IReadOnlyDictionary<string, int> AssetCustodyEventTypeIds,
IReadOnlyDictionary<string, int> CustodyRoleIds)
{
public int AssetCustodyEventTypesFallback()
=> AssetCustodyEventTypeIds.TryGetValue("Has", out var hasId)
? hasId
: AssetCustodyEventTypeIds.Values.First();
}
private sealed record ImportAppearance(int CharacterId, string? RoleInScene, int? Importance, string? Notes);

View File

@ -196,8 +196,10 @@ public sealed class ImportPreview
public int StoryAssetCount => Package?.StoryAssets.Count ?? 0;
public int AssetStateCount => Package?.StoryAssets.Sum(asset => asset.States.Count) ?? 0;
public int AssetEventCount => Package?.StoryAssets.Sum(asset => asset.Events.Count) ?? 0;
public int AssetCustodyCount => Package?.StoryAssets.Count(asset => !string.IsNullOrWhiteSpace(asset.InitialOwnerCharacterName)) ?? 0;
public int ResolvedAssetStateCount => Math.Max(0, AssetStateCount - UnresolvedAssetReferences.Count(x => x.Contains("state", StringComparison.OrdinalIgnoreCase)));
public int ResolvedAssetEventCount => Math.Max(0, AssetEventCount - UnresolvedAssetReferences.Count(x => x.Contains("event", StringComparison.OrdinalIgnoreCase)));
public int ResolvedAssetCustodyCount => Math.Max(0, AssetCustodyCount - Package?.StoryAssets.Count(asset => !string.IsNullOrWhiteSpace(asset.InitialOwnerCharacterName) && UnmatchedAssetOwnerNames.Contains(asset.InitialOwnerCharacterName.Trim(), StringComparer.OrdinalIgnoreCase)) ?? 0);
public int ChapterCount => Package?.Books.Sum(book => book.Chapters.Count) ?? 0;
public int SceneCount => Package?.Books.Sum(book => book.Chapters.Sum(chapter => chapter.Scenes.Count)) ?? 0;
public int SceneCharacterAppearanceCount => Package?.Books.Sum(book => book.Chapters.Sum(chapter => chapter.Scenes.Sum(scene => scene.SceneCharacters.Count + AutoPovAppearanceCount(scene)))) ?? 0;

View File

@ -108,6 +108,10 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
builder.AppendLine($"Plot lines: {preview.PlotLineCount}");
builder.AppendLine($"Plot threads: {preview.PlotThreadCount}");
builder.AppendLine($"Thread events: {preview.ThreadEventCount}");
builder.AppendLine($"Story assets: {preview.StoryAssetCount}");
builder.AppendLine($"Asset states: {preview.AssetStateCount}");
builder.AppendLine($"Asset events: {preview.AssetEventCount}");
builder.AppendLine($"Asset custody links: {preview.AssetCustodyCount}");
builder.AppendLine($"Books: {preview.BookCount}");
builder.AppendLine($"Chapters: {preview.ChapterCount}");
builder.AppendLine($"Scenes: {preview.SceneCount}");
@ -119,6 +123,9 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
AppendReportSection(builder, "Unmatched location names", preview.UnmatchedLocationNames);
AppendReportSection(builder, "Unmatched scene character names", preview.UnmatchedSceneCharacterNames);
AppendReportSection(builder, "Unresolved thread event references", preview.UnresolvedThreadEventReferences);
AppendReportSection(builder, "Unresolved asset references", preview.UnresolvedAssetReferences);
AppendReportSection(builder, "Unmatched asset owners", preview.UnmatchedAssetOwnerNames);
AppendReportSection(builder, "Unmatched asset locations", preview.UnmatchedAssetLocationNames);
return builder.ToString();
}
@ -174,7 +181,10 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
UnmatchedPovNames = FindUnmatchedPovNames(package),
UnmatchedLocationNames = FindUnmatchedLocationNames(package),
UnmatchedSceneCharacterNames = FindUnmatchedSceneCharacterNames(package),
UnresolvedThreadEventReferences = FindUnresolvedThreadEventReferences(package)
UnresolvedThreadEventReferences = FindUnresolvedThreadEventReferences(package),
UnresolvedAssetReferences = FindUnresolvedAssetReferences(package),
UnmatchedAssetOwnerNames = FindUnmatchedAssetOwnerNames(package),
UnmatchedAssetLocationNames = FindUnmatchedAssetLocationNames(package)
};
}
@ -196,6 +206,7 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
ValidateCharacters(package, result);
ValidateLocations(package, result);
ValidatePlotLines(package, result);
ValidateStoryAssets(package, result);
if (string.IsNullOrWhiteSpace(package.PackageVersion))
{
@ -344,9 +355,115 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
result.Warnings.Add($"Thread event scene reference could not be resolved and will be skipped: {reference}");
}
foreach (var reference in FindUnresolvedAssetReferences(package))
{
result.Warnings.Add($"Asset state/event scene reference could not be resolved and will be skipped: {reference}");
}
foreach (var name in FindUnmatchedAssetOwnerNames(package))
{
result.Warnings.Add($"Story asset owner \"{name}\" does not match a top-level imported character.");
}
foreach (var name in FindUnmatchedAssetLocationNames(package))
{
result.Warnings.Add($"Story asset location \"{name}\" does not match a top-level imported location.");
}
return result;
}
private static void ValidateStoryAssets(PlotLineImportPackage package, ImportValidationResult result)
{
foreach (var group in package.StoryAssets
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
.Where(x => x.Count() > 1))
{
result.Errors.Add($"Story asset \"{group.Key}\" appears more than once in the import package.");
}
for (var assetIndex = 0; assetIndex < package.StoryAssets.Count; assetIndex++)
{
var asset = package.StoryAssets[assetIndex];
var assetLabel = string.IsNullOrWhiteSpace(asset.Name) ? $"Story asset {assetIndex + 1}" : asset.Name.Trim();
if (string.IsNullOrWhiteSpace(asset.Name))
{
result.Errors.Add($"Story asset {assetIndex + 1} is missing a name.");
}
if (DisplayName(asset.DisplayName, asset.Name).Length > TitleMaxLength)
{
result.Errors.Add($"{assetLabel} name/displayName is longer than {TitleMaxLength} characters.");
}
ValidateLargeText(result, $"{assetLabel} description", asset.Description);
ValidateLargeText(result, $"{assetLabel} notes", asset.Notes);
foreach (var duplicate in asset.States
.Where(HasSceneOrderReference)
.GroupBy(AssetStateSceneKey)
.Where(x => x.Count() > 1))
{
result.Errors.Add($"{assetLabel} has duplicate states for scene {duplicate.Key}.");
}
foreach (var duplicate in asset.Events
.Where(HasSceneOrderReference)
.GroupBy(AssetEventSceneKey)
.Where(x => x.Count() > 1))
{
result.Errors.Add($"{assetLabel} has duplicate events for scene {duplicate.Key}.");
}
for (var stateIndex = 0; stateIndex < asset.States.Count; stateIndex++)
{
var state = asset.States[stateIndex];
var stateLabel = string.IsNullOrWhiteSpace(state.StateName) ? $"State {stateIndex + 1}" : state.StateName.Trim();
if (string.IsNullOrWhiteSpace(state.StateName) && string.IsNullOrWhiteSpace(state.Description))
{
result.Errors.Add($"{assetLabel} state {stateIndex + 1} needs stateName or description.");
}
if (!HasSceneOrderReference(state) && string.IsNullOrWhiteSpace(state.SceneRef))
{
result.Warnings.Add($"{assetLabel} / {stateLabel} is missing bookOrder/chapterOrder/sceneOrder.");
}
if (!string.IsNullOrWhiteSpace(state.StateName) && state.StateName.Trim().Length > 100)
{
result.Warnings.Add($"{assetLabel} / {stateLabel} stateName is longer than 100 characters and may not match an existing asset state.");
}
ValidateLargeText(result, $"{assetLabel} / {stateLabel} description", state.Description);
ValidateLargeText(result, $"{assetLabel} / {stateLabel} notes", state.Notes);
}
for (var eventIndex = 0; eventIndex < asset.Events.Count; eventIndex++)
{
var assetEvent = asset.Events[eventIndex];
var eventLabel = string.IsNullOrWhiteSpace(assetEvent.Title) ? $"Event {eventIndex + 1}" : assetEvent.Title.Trim();
if (string.IsNullOrWhiteSpace(assetEvent.Title) && string.IsNullOrWhiteSpace(assetEvent.Summary))
{
result.Errors.Add($"{assetLabel} event {eventIndex + 1} needs a title or summary.");
}
if (!HasSceneOrderReference(assetEvent) && string.IsNullOrWhiteSpace(assetEvent.SceneRef))
{
result.Warnings.Add($"{assetLabel} / {eventLabel} is missing bookOrder/chapterOrder/sceneOrder.");
}
if (!string.IsNullOrWhiteSpace(assetEvent.Title) && assetEvent.Title.Trim().Length > TitleMaxLength)
{
result.Errors.Add($"{assetLabel} / {eventLabel} title is longer than {TitleMaxLength} characters.");
}
ValidateLargeText(result, $"{assetLabel} / {eventLabel} summary", assetEvent.Summary);
ValidateLargeText(result, $"{assetLabel} / {eventLabel} notes", assetEvent.Notes);
}
}
}
private static void ValidatePlotLines(PlotLineImportPackage package, ImportValidationResult result)
{
foreach (var group in package.PlotLines
@ -602,12 +719,81 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
.ToList();
}
private static IReadOnlyList<string> FindUnresolvedAssetReferences(PlotLineImportPackage package)
{
var sceneKeys = package.Books
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => $"{book.SeriesOrder}/{chapter.Order}/{scene.Order}")))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var stateReferences = package.StoryAssets
.SelectMany(asset => asset.States.Select((state, index) => new
{
AssetName = DisplayName(asset.DisplayName, asset.Name),
Label = string.IsNullOrWhiteSpace(state.StateName) ? $"State {index + 1}" : state.StateName.Trim(),
State = state
}))
.Where(x => !HasSceneOrderReference(x.State) || !sceneKeys.Contains(AssetStateSceneKey(x.State)))
.Select(x => $"{x.AssetName} / state / {x.Label} -> {AssetStateSceneKey(x.State)}");
var eventReferences = package.StoryAssets
.SelectMany(asset => asset.Events.Select((assetEvent, index) => new
{
AssetName = DisplayName(asset.DisplayName, asset.Name),
Label = string.IsNullOrWhiteSpace(assetEvent.Title) ? $"Event {index + 1}" : assetEvent.Title.Trim(),
Event = assetEvent
}))
.Where(x => !HasSceneOrderReference(x.Event) || !sceneKeys.Contains(AssetEventSceneKey(x.Event)))
.Select(x => $"{x.AssetName} / event / {x.Label} -> {AssetEventSceneKey(x.Event)}");
return stateReferences
.Concat(eventReferences)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x)
.ToList();
}
private static IReadOnlyList<string> FindUnmatchedAssetOwnerNames(PlotLineImportPackage package)
{
var characterNames = CharacterNames(package);
return package.StoryAssets
.Select(asset => asset.InitialOwnerCharacterName?.Trim())
.Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.CharacterName?.Trim())))
.Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name)
.ToList()!;
}
private static IReadOnlyList<string> FindUnmatchedAssetLocationNames(PlotLineImportPackage package)
{
var locationNames = LocationNames(package);
return package.StoryAssets
.Select(asset => asset.InitialLocationName?.Trim())
.Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.LocationName?.Trim())))
.Where(name => !string.IsNullOrWhiteSpace(name) && !locationNames.Contains(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name)
.ToList()!;
}
private static bool HasSceneOrderReference(ImportThreadEventDto threadEvent)
=> threadEvent.BookOrder.HasValue && threadEvent.ChapterOrder.HasValue && threadEvent.SceneOrder.HasValue;
private static string EventSceneKey(ImportThreadEventDto threadEvent)
=> $"{threadEvent.BookOrder?.ToString() ?? "?"}/{threadEvent.ChapterOrder?.ToString() ?? "?"}/{threadEvent.SceneOrder?.ToString() ?? "?"}";
private static bool HasSceneOrderReference(ImportAssetStateDto state)
=> state.BookOrder.HasValue && state.ChapterOrder.HasValue && state.SceneOrder.HasValue;
private static bool HasSceneOrderReference(ImportAssetEventDto assetEvent)
=> assetEvent.BookOrder.HasValue && assetEvent.ChapterOrder.HasValue && assetEvent.SceneOrder.HasValue;
private static string AssetStateSceneKey(ImportAssetStateDto state)
=> $"{state.BookOrder?.ToString() ?? "?"}/{state.ChapterOrder?.ToString() ?? "?"}/{state.SceneOrder?.ToString() ?? "?"}";
private static string AssetEventSceneKey(ImportAssetEventDto assetEvent)
=> $"{assetEvent.BookOrder?.ToString() ?? "?"}/{assetEvent.ChapterOrder?.ToString() ?? "?"}/{assetEvent.SceneOrder?.ToString() ?? "?"}";
private static HashSet<string> CharacterNames(PlotLineImportPackage package)
=> package.Characters
.Where(x => !string.IsNullOrWhiteSpace(x.Name))

View File

@ -21,7 +21,7 @@ public sealed class ImportIndexViewModel
public static string SampleJson => """
{
"packageVersion": "1.0",
"source": "JSON Import Phase 1D sample",
"source": "JSON Import Phase 1E sample",
"project": {
"title": "The Glass Meridian",
"description": "A placeholder two-book fantasy outline.",
@ -96,6 +96,58 @@ public sealed class ImportIndexViewModel
]
}
],
"storyAssets": [
{
"name": "Brass key",
"displayName": "The Brass Key",
"type": "Object",
"importance": 9,
"description": "A warm brass key with a notch pattern that does not match city locks.",
"notes": "Tracks the mystery through book one.",
"initialOwnerCharacterName": "Mara",
"initialLocationName": "Old Arcade",
"states": [
{ "bookOrder": 1, "chapterOrder": 1, "sceneOrder": 1, "stateName": "Known", "description": "Mara finds the key in the old arcade.", "notes": "First asset state." },
{ "bookOrder": 1, "chapterOrder": 2, "sceneOrder": 1, "stateName": "Changed", "description": "The key reacts to the lantern court map.", "notes": "Shows the key is more than a door tool." }
],
"events": [
{ "bookOrder": 1, "chapterOrder": 1, "sceneOrder": 1, "eventType": "Introduced", "title": "Key found", "summary": "Mara pockets the key after the impossible doorway appears.", "characterName": "Mara", "locationName": "Old Arcade", "notes": "Creates initial custody." },
{ "bookOrder": 1, "chapterOrder": 2, "sceneOrder": 1, "eventType": "Used", "title": "Key warms near the map", "summary": "The brass key warms in Mara's hand near the incomplete map.", "characterName": "Mara", "locationName": "Lantern Court", "notes": "Moves the asset to the hidden city." }
]
},
{
"name": "Signal plate",
"displayName": "The Signal Plate",
"type": "Clue",
"importance": 8,
"description": "A damaged brass plate that carries a warning from the coast.",
"initialOwnerCharacterName": "Tovin",
"initialLocationName": "Clockwork Shore",
"states": [
{ "bookOrder": 2, "chapterOrder": 1, "sceneOrder": 2, "stateName": "Damaged", "description": "The plate is cracked by salt and failed gears." },
{ "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 1, "stateName": "Known", "description": "Mara translates the warning." }
],
"events": [
{ "bookOrder": 2, "chapterOrder": 1, "sceneOrder": 2, "eventType": "Discovered", "title": "Plate recovered", "summary": "Tovin pulls the plate free from the broken machine.", "characterName": "Tovin", "locationName": "Clockwork Shore" },
{ "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 1, "eventType": "Revealed", "title": "Warning translated", "summary": "The plate names the missing captain and points toward Sera's route.", "characterName": "Mara", "locationName": "Clockwork Shore" }
]
},
{
"name": "Tide chart",
"displayName": "Ilen's Tide Chart",
"type": "Document",
"importance": 6,
"description": "A folded chart showing when the causeway opens.",
"initialOwnerCharacterName": "Ilen",
"initialLocationName": "Tidal causeway",
"states": [
{ "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 2, "stateName": "Known", "description": "Ilen confirms the safe crossing window." }
],
"events": [
{ "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 2, "eventType": "Used", "title": "Crossing window found", "summary": "Ilen uses the chart to time the causeway crossing.", "characterName": "Ilen", "locationName": "Tidal causeway" }
]
}
],
"books": [
{
"title": "Book One",

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 1D package, preview it, confirm the summary, then import a new project.</p>
<p class="lead-text">Paste or upload a Phase 1E package, preview it, confirm the summary, 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,11 +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>, plot line <code>threads</code>, thread <code>events</code>, POV/location mapping, date/time text, purpose/outcome text, and <code>sceneCharacters</code>.</p>
<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>, 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>Not imported yet</h3>
<p>No AI extraction, Word/text parsing, story assets, asset states/custody, dependencies, relationship graphs, merge/update, or export logic. Thread events link to scenes with <code>bookOrder</code>, <code>chapterOrder</code>, and <code>sceneOrder</code>; <code>sceneRef</code> is accepted as future-friendly text only.</p>
<p>No AI extraction, Word/text parsing, manuscript parsing, scene dependency import, relationship graphs, merge/update, or export logic. Thread events and asset states/events link to scenes with <code>bookOrder</code>, <code>chapterOrder</code>, and <code>sceneOrder</code>; <code>sceneRef</code> is accepted as future-friendly text only.</p>
</div>
</div>
</details>
@ -93,6 +93,10 @@
<span><strong>@preview.PlotLineCount</strong> plot lines</span>
<span><strong>@preview.PlotThreadCount</strong> threads</span>
<span><strong>@preview.ResolvedThreadEventCount</strong> events</span>
<span><strong>@preview.StoryAssetCount</strong> assets</span>
<span><strong>@preview.ResolvedAssetStateCount</strong> asset states</span>
<span><strong>@preview.ResolvedAssetEventCount</strong> asset events</span>
<span><strong>@preview.ResolvedAssetCustodyCount</strong> custody links</span>
</div>
</div>
@ -131,7 +135,7 @@
@if (preview.Package is not null)
{
@if (preview.UnmatchedPovNames.Any() || preview.UnmatchedLocationNames.Any() || preview.UnmatchedSceneCharacterNames.Any() || preview.UnresolvedThreadEventReferences.Any())
@if (preview.UnmatchedPovNames.Any() || preview.UnmatchedLocationNames.Any() || preview.UnmatchedSceneCharacterNames.Any() || preview.UnresolvedThreadEventReferences.Any() || preview.UnresolvedAssetReferences.Any() || preview.UnmatchedAssetOwnerNames.Any() || preview.UnmatchedAssetLocationNames.Any())
{
<div class="import-mapping-summary">
@if (preview.UnmatchedPovNames.Any())
@ -150,6 +154,18 @@
{
<span>Unresolved thread events: @string.Join(", ", preview.UnresolvedThreadEventReferences)</span>
}
@if (preview.UnresolvedAssetReferences.Any())
{
<span>Unresolved asset references: @string.Join(", ", preview.UnresolvedAssetReferences)</span>
}
@if (preview.UnmatchedAssetOwnerNames.Any())
{
<span>Unmatched asset owners/events: @string.Join(", ", preview.UnmatchedAssetOwnerNames)</span>
}
@if (preview.UnmatchedAssetLocationNames.Any())
{
<span>Unmatched asset locations: @string.Join(", ", preview.UnmatchedAssetLocationNames)</span>
}
</div>
}
@ -213,9 +229,27 @@
</div>
}
@if (preview.Package.StoryAssets.Any())
{
<div class="import-tree mt-3">
@foreach (var asset in preview.Package.StoryAssets)
{
<details class="import-tree-book">
<summary>
<strong>Asset: @(string.IsNullOrWhiteSpace(asset.DisplayName) ? asset.Name : asset.DisplayName)</strong>
<span>@asset.States.Count states / @asset.Events.Count events</span>
</summary>
<ul>
<li>Type: @(asset.Type ?? "Not set") / Initial owner: @(asset.InitialOwnerCharacterName ?? "Not set") / Initial location: @(asset.InitialLocationName ?? "Not set")</li>
</ul>
</details>
}
</div>
}
<section class="import-confirmation mt-3">
<h3>Confirm import</h3>
<p>This will create <strong>@preview.Package.Project.Title</strong> with <strong>@preview.BookCount</strong> books, <strong>@preview.ChapterCount</strong> chapters, <strong>@preview.SceneCount</strong> scenes, <strong>@preview.CharacterCount</strong> characters, <strong>@preview.LocationCount</strong> locations, <strong>@preview.SceneCharacterAppearanceCount</strong> scene character appearances, <strong>@preview.PlotLineCount</strong> plot lines, <strong>@preview.PlotThreadCount</strong> plot threads, and <strong>@preview.ResolvedThreadEventCount</strong> resolved thread events.</p>
<p>This will create <strong>@preview.Package.Project.Title</strong> with <strong>@preview.BookCount</strong> books, <strong>@preview.ChapterCount</strong> chapters, <strong>@preview.SceneCount</strong> scenes, <strong>@preview.CharacterCount</strong> characters, <strong>@preview.LocationCount</strong> locations, <strong>@preview.SceneCharacterAppearanceCount</strong> scene character appearances, <strong>@preview.PlotLineCount</strong> plot lines, <strong>@preview.PlotThreadCount</strong> plot threads, <strong>@preview.ResolvedThreadEventCount</strong> resolved thread events, <strong>@preview.StoryAssetCount</strong> story assets, <strong>@preview.ResolvedAssetStateCount</strong> resolved asset states, <strong>@preview.ResolvedAssetEventCount</strong> resolved asset events, and <strong>@preview.ResolvedAssetCustodyCount</strong> custody links.</p>
@if (preview.HasDuplicateProjectTitle)
{
<p class="muted">Because this title already exists, the created project will be named <strong>@preview.DuplicateImportProjectTitle</strong> if you continue.</p>

View File

@ -26,6 +26,10 @@
<span><strong>@Model.PlotLinesCreated</strong> plot lines created</span>
<span><strong>@Model.PlotThreadsCreated</strong> plot threads created</span>
<span><strong>@Model.ThreadEventsCreated</strong> thread events created</span>
<span><strong>@Model.StoryAssetsCreated</strong> story assets created</span>
<span><strong>@Model.AssetStatesCreated</strong> asset states created</span>
<span><strong>@Model.AssetEventsCreated</strong> asset events created</span>
<span><strong>@Model.AssetCustodyEventsCreated</strong> custody records created</span>
</div>
@if (Model.SkippedOptionalFields.Any())