Importer Phase 1D
This commit is contained in:
parent
a39b5cb7d8
commit
ed341587de
@ -13,7 +13,7 @@ public interface IImportRepository
|
|||||||
|
|
||||||
public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : 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)
|
public async Task<bool> ProjectTitleExistsAsync(string projectTitle)
|
||||||
{
|
{
|
||||||
@ -46,6 +46,10 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
var plotLinesCreated = 0;
|
var plotLinesCreated = 0;
|
||||||
var plotThreadsCreated = 0;
|
var plotThreadsCreated = 0;
|
||||||
var threadEventsCreated = 0;
|
var threadEventsCreated = 0;
|
||||||
|
var storyAssetsCreated = 0;
|
||||||
|
var assetStatesCreated = 0;
|
||||||
|
var assetEventsCreated = 0;
|
||||||
|
var assetCustodyEventsCreated = 0;
|
||||||
var skippedFields = GetSkippedOptionalFields(package);
|
var skippedFields = GetSkippedOptionalFields(package);
|
||||||
var unresolvedWarnings = GetUnresolvedWarnings(package);
|
var unresolvedWarnings = GetUnresolvedWarnings(package);
|
||||||
var sceneMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
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();
|
transaction.Commit();
|
||||||
return new ImportResult
|
return new ImportResult
|
||||||
{
|
{
|
||||||
@ -370,6 +506,10 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
PlotLinesCreated = plotLinesCreated,
|
PlotLinesCreated = plotLinesCreated,
|
||||||
PlotThreadsCreated = plotThreadsCreated,
|
PlotThreadsCreated = plotThreadsCreated,
|
||||||
ThreadEventsCreated = threadEventsCreated,
|
ThreadEventsCreated = threadEventsCreated,
|
||||||
|
StoryAssetsCreated = storyAssetsCreated,
|
||||||
|
AssetStatesCreated = assetStatesCreated,
|
||||||
|
AssetEventsCreated = assetEventsCreated,
|
||||||
|
AssetCustodyEventsCreated = assetCustodyEventsCreated,
|
||||||
SkippedOptionalFields = skippedFields,
|
SkippedOptionalFields = skippedFields,
|
||||||
UnresolvedWarnings = unresolvedWarnings,
|
UnresolvedWarnings = unresolvedWarnings,
|
||||||
Message = "Import complete."
|
Message = "Import complete."
|
||||||
@ -415,6 +555,21 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
var threadEventTypes = (await connection.QueryAsync<ImportLookupRow>(
|
var threadEventTypes = (await connection.QueryAsync<ImportLookupRow>(
|
||||||
"SELECT ThreadEventTypeID AS LookupID, TypeName FROM dbo.ThreadEventTypes WHERE IsActive = 1;",
|
"SELECT ThreadEventTypeID AS LookupID, TypeName FROM dbo.ThreadEventTypes WHERE IsActive = 1;",
|
||||||
transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
|
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)
|
if (!revisionStatusId.HasValue || !timeModeId.HasValue || !timeConfidenceId.HasValue)
|
||||||
{
|
{
|
||||||
@ -431,7 +586,12 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
plotLineTypes,
|
plotLineTypes,
|
||||||
threadTypes,
|
threadTypes,
|
||||||
threadStatuses,
|
threadStatuses,
|
||||||
threadEventTypes);
|
threadEventTypes,
|
||||||
|
assetKinds,
|
||||||
|
assetStates,
|
||||||
|
assetEventTypes,
|
||||||
|
assetCustodyEventTypes,
|
||||||
|
custodyRoles);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatBookTitle(ImportBookDto book)
|
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.");
|
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;
|
return skipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -522,6 +687,48 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
warnings.Add($"Unresolved thread event scene reference: {reference}");
|
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;
|
return warnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -656,6 +863,62 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
: lookupIds.ThreadEventTypeIds.Values.First();
|
: 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)
|
private static int ClampImportance(int? importance)
|
||||||
=> Math.Clamp(importance ?? 5, 1, 10);
|
=> Math.Clamp(importance ?? 5, 1, 10);
|
||||||
|
|
||||||
@ -670,6 +933,28 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
return false;
|
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)
|
private static string SceneKey(int bookOrder, int chapterOrder, int sceneOrder)
|
||||||
=> $"{bookOrder}/{chapterOrder}/{sceneOrder}";
|
=> $"{bookOrder}/{chapterOrder}/{sceneOrder}";
|
||||||
|
|
||||||
@ -724,7 +1009,18 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
IReadOnlyDictionary<string, int> PlotLineTypeIds,
|
IReadOnlyDictionary<string, int> PlotLineTypeIds,
|
||||||
IReadOnlyDictionary<string, int> ThreadTypeIds,
|
IReadOnlyDictionary<string, int> ThreadTypeIds,
|
||||||
IReadOnlyDictionary<string, int> ThreadStatusIds,
|
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);
|
private sealed record ImportAppearance(int CharacterId, string? RoleInScene, int? Importance, string? Notes);
|
||||||
|
|
||||||
|
|||||||
@ -196,8 +196,10 @@ public sealed class ImportPreview
|
|||||||
public int StoryAssetCount => Package?.StoryAssets.Count ?? 0;
|
public int StoryAssetCount => Package?.StoryAssets.Count ?? 0;
|
||||||
public int AssetStateCount => Package?.StoryAssets.Sum(asset => asset.States.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 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 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 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 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 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;
|
public int SceneCharacterAppearanceCount => Package?.Books.Sum(book => book.Chapters.Sum(chapter => chapter.Scenes.Sum(scene => scene.SceneCharacters.Count + AutoPovAppearanceCount(scene)))) ?? 0;
|
||||||
|
|||||||
@ -108,6 +108,10 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
|
|||||||
builder.AppendLine($"Plot lines: {preview.PlotLineCount}");
|
builder.AppendLine($"Plot lines: {preview.PlotLineCount}");
|
||||||
builder.AppendLine($"Plot threads: {preview.PlotThreadCount}");
|
builder.AppendLine($"Plot threads: {preview.PlotThreadCount}");
|
||||||
builder.AppendLine($"Thread events: {preview.ThreadEventCount}");
|
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($"Books: {preview.BookCount}");
|
||||||
builder.AppendLine($"Chapters: {preview.ChapterCount}");
|
builder.AppendLine($"Chapters: {preview.ChapterCount}");
|
||||||
builder.AppendLine($"Scenes: {preview.SceneCount}");
|
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 location names", preview.UnmatchedLocationNames);
|
||||||
AppendReportSection(builder, "Unmatched scene character names", preview.UnmatchedSceneCharacterNames);
|
AppendReportSection(builder, "Unmatched scene character names", preview.UnmatchedSceneCharacterNames);
|
||||||
AppendReportSection(builder, "Unresolved thread event references", preview.UnresolvedThreadEventReferences);
|
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();
|
return builder.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,7 +181,10 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
|
|||||||
UnmatchedPovNames = FindUnmatchedPovNames(package),
|
UnmatchedPovNames = FindUnmatchedPovNames(package),
|
||||||
UnmatchedLocationNames = FindUnmatchedLocationNames(package),
|
UnmatchedLocationNames = FindUnmatchedLocationNames(package),
|
||||||
UnmatchedSceneCharacterNames = FindUnmatchedSceneCharacterNames(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);
|
ValidateCharacters(package, result);
|
||||||
ValidateLocations(package, result);
|
ValidateLocations(package, result);
|
||||||
ValidatePlotLines(package, result);
|
ValidatePlotLines(package, result);
|
||||||
|
ValidateStoryAssets(package, result);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(package.PackageVersion))
|
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}");
|
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;
|
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)
|
private static void ValidatePlotLines(PlotLineImportPackage package, ImportValidationResult result)
|
||||||
{
|
{
|
||||||
foreach (var group in package.PlotLines
|
foreach (var group in package.PlotLines
|
||||||
@ -602,12 +719,81 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
|
|||||||
.ToList();
|
.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)
|
private static bool HasSceneOrderReference(ImportThreadEventDto threadEvent)
|
||||||
=> threadEvent.BookOrder.HasValue && threadEvent.ChapterOrder.HasValue && threadEvent.SceneOrder.HasValue;
|
=> threadEvent.BookOrder.HasValue && threadEvent.ChapterOrder.HasValue && threadEvent.SceneOrder.HasValue;
|
||||||
|
|
||||||
private static string EventSceneKey(ImportThreadEventDto threadEvent)
|
private static string EventSceneKey(ImportThreadEventDto threadEvent)
|
||||||
=> $"{threadEvent.BookOrder?.ToString() ?? "?"}/{threadEvent.ChapterOrder?.ToString() ?? "?"}/{threadEvent.SceneOrder?.ToString() ?? "?"}";
|
=> $"{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)
|
private static HashSet<string> CharacterNames(PlotLineImportPackage package)
|
||||||
=> package.Characters
|
=> package.Characters
|
||||||
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
||||||
|
|||||||
@ -21,7 +21,7 @@ public sealed class ImportIndexViewModel
|
|||||||
public static string SampleJson => """
|
public static string SampleJson => """
|
||||||
{
|
{
|
||||||
"packageVersion": "1.0",
|
"packageVersion": "1.0",
|
||||||
"source": "JSON Import Phase 1D sample",
|
"source": "JSON Import Phase 1E sample",
|
||||||
"project": {
|
"project": {
|
||||||
"title": "The Glass Meridian",
|
"title": "The Glass Meridian",
|
||||||
"description": "A placeholder two-book fantasy outline.",
|
"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": [
|
"books": [
|
||||||
{
|
{
|
||||||
"title": "Book One",
|
"title": "Book One",
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Tools</p>
|
<p class="eyebrow">Tools</p>
|
||||||
<h1>JSON Import</h1>
|
<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>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<a class="btn btn-outline-primary" asp-action="Sample">Download sample JSON</a>
|
<a class="btn btn-outline-primary" asp-action="Sample">Download sample JSON</a>
|
||||||
@ -65,11 +65,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>Optional</h3>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h3>Not imported yet</h3>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@ -93,6 +93,10 @@
|
|||||||
<span><strong>@preview.PlotLineCount</strong> plot lines</span>
|
<span><strong>@preview.PlotLineCount</strong> plot lines</span>
|
||||||
<span><strong>@preview.PlotThreadCount</strong> threads</span>
|
<span><strong>@preview.PlotThreadCount</strong> threads</span>
|
||||||
<span><strong>@preview.ResolvedThreadEventCount</strong> events</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -131,7 +135,7 @@
|
|||||||
|
|
||||||
@if (preview.Package is not null)
|
@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">
|
<div class="import-mapping-summary">
|
||||||
@if (preview.UnmatchedPovNames.Any())
|
@if (preview.UnmatchedPovNames.Any())
|
||||||
@ -150,6 +154,18 @@
|
|||||||
{
|
{
|
||||||
<span>Unresolved thread events: @string.Join(", ", preview.UnresolvedThreadEventReferences)</span>
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,9 +229,27 @@
|
|||||||
</div>
|
</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">
|
<section class="import-confirmation mt-3">
|
||||||
<h3>Confirm import</h3>
|
<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)
|
@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>
|
<p class="muted">Because this title already exists, the created project will be named <strong>@preview.DuplicateImportProjectTitle</strong> if you continue.</p>
|
||||||
|
|||||||
@ -26,6 +26,10 @@
|
|||||||
<span><strong>@Model.PlotLinesCreated</strong> plot lines created</span>
|
<span><strong>@Model.PlotLinesCreated</strong> plot lines created</span>
|
||||||
<span><strong>@Model.PlotThreadsCreated</strong> plot threads created</span>
|
<span><strong>@Model.PlotThreadsCreated</strong> plot threads created</span>
|
||||||
<span><strong>@Model.ThreadEventsCreated</strong> thread events 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>
|
</div>
|
||||||
|
|
||||||
@if (Model.SkippedOptionalFields.Any())
|
@if (Model.SkippedOptionalFields.Any())
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user