Import Phase 1F

This commit is contained in:
Nick Beckley 2026-06-02 12:51:47 +01:00
parent ed341587de
commit b684146248
6 changed files with 354 additions and 9 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 1E";
private const string ImportMarker = "Imported via JSON Import Phase 1F";
public async Task<bool> ProjectTitleExistsAsync(string projectTitle)
{
@ -50,6 +50,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
var assetStatesCreated = 0;
var assetEventsCreated = 0;
var assetCustodyEventsCreated = 0;
var sceneDependenciesCreated = 0;
var skippedFields = GetSkippedOptionalFields(package);
var unresolvedWarnings = GetUnresolvedWarnings(package);
var sceneMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
@ -491,6 +492,45 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
}
}
var importedSceneDependencyKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var dependency in package.SceneDependencies)
{
if (!TryResolveSceneReference(sceneMap, dependency.FromScene, out var sourceSceneId)
|| !TryResolveSceneReference(sceneMap, dependency.ToScene, out var targetSceneId)
|| sourceSceneId == targetSceneId)
{
continue;
}
var dependencyTypeId = ResolveSceneDependencyTypeId(lookupIds, dependency.DependencyType);
var dependencyKey = $"{sourceSceneId}/{targetSceneId}/{dependencyTypeId}";
if (!importedSceneDependencyKeys.Add(dependencyKey))
{
continue;
}
await connection.QuerySingleAsync<int>(
"dbo.SceneDependency_Save",
new
{
SceneDependencyID = 0,
ProjectID = projectId,
SourceSceneID = sourceSceneId,
TargetSceneID = targetSceneId,
SceneDependencyTypeID = dependencyTypeId,
Description = CombineText(
dependency.Description,
MetadataLine("Dependency type", dependency.DependencyType),
MetadataLine("Strength", dependency.Strength),
dependency.Notes,
ImportMarker),
IsHardDependency = ResolveSceneDependencyStrength(dependency.Strength)
},
transaction,
commandType: CommandType.StoredProcedure);
sceneDependenciesCreated++;
}
transaction.Commit();
return new ImportResult
{
@ -510,6 +550,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
AssetStatesCreated = assetStatesCreated,
AssetEventsCreated = assetEventsCreated,
AssetCustodyEventsCreated = assetCustodyEventsCreated,
SceneDependenciesCreated = sceneDependenciesCreated,
SkippedOptionalFields = skippedFields,
UnresolvedWarnings = unresolvedWarnings,
Message = "Import complete."
@ -555,6 +596,9 @@ 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 sceneDependencyTypes = (await connection.QueryAsync<ImportLookupRow>(
"SELECT SceneDependencyTypeID AS LookupID, TypeName FROM dbo.SceneDependencyTypes 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);
@ -587,6 +631,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
threadTypes,
threadStatuses,
threadEventTypes,
sceneDependencyTypes,
assetKinds,
assetStates,
assetEventTypes,
@ -640,6 +685,11 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
skipped.Add("Asset event character names were stored in event descriptions.");
}
if (package.SceneDependencies.Any(dependency => !string.IsNullOrWhiteSpace(dependency.DependencyType) || !string.IsNullOrWhiteSpace(dependency.Strength)))
{
skipped.Add("Scene dependency type and strength labels were mapped to existing dependency fields and preserved in descriptions.");
}
return skipped;
}
@ -729,6 +779,21 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
warnings.Add($"Unresolved asset event scene reference: {reference}");
}
foreach (var reference in package.SceneDependencies.SelectMany((dependency, index) => new[]
{
new { Label = $"Dependency {index + 1} fromScene", Reference = dependency.FromScene },
new { Label = $"Dependency {index + 1} toScene", Reference = dependency.ToScene }
})
.Where(x => x.Reference?.BookOrder.HasValue != true
|| !x.Reference.ChapterOrder.HasValue
|| !x.Reference.SceneOrder.HasValue
|| !sceneKeys.Contains(SceneKey(x.Reference.BookOrder.GetValueOrDefault(), x.Reference.ChapterOrder.GetValueOrDefault(), x.Reference.SceneOrder.GetValueOrDefault())))
.Select(x => $"{x.Label} -> {SceneReferenceKey(x.Reference)}")
.Distinct(StringComparer.OrdinalIgnoreCase))
{
warnings.Add($"Unresolved scene dependency reference: {reference}");
}
return warnings;
}
@ -863,6 +928,48 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
: lookupIds.ThreadEventTypeIds.Values.First();
}
private static int ResolveSceneDependencyTypeId(ImportLookupIds lookupIds, string? dependencyType)
{
var normalized = Clean(dependencyType);
if (normalized.Length > 0 && lookupIds.SceneDependencyTypeIds.TryGetValue(normalized, out var id))
{
return id;
}
var mappedType = normalized.ToLowerInvariant() switch
{
"causes" or "requires" or "blocks" or "follows" => "Must Occur Before",
"reveals" or "foreshadows" or "paysoff" or "pays off" => "Reveal Setup",
"contradicts" => "Knowledge Dependency",
"mirrors" => "Emotional Setup",
_ => "Must Occur Before"
};
return lookupIds.SceneDependencyTypeIds.TryGetValue(mappedType, out var mappedId)
? mappedId
: lookupIds.SceneDependencyTypeIds.Values.First();
}
private static bool ResolveSceneDependencyStrength(string? strength)
{
var normalized = Clean(strength);
if (normalized.Length == 0)
{
return false;
}
if (int.TryParse(normalized, out var numeric))
{
return numeric >= 7;
}
return normalized.Equals("Hard", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("Strong", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("Required", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("High", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("Critical", StringComparison.OrdinalIgnoreCase);
}
private static int ResolveAssetKindId(ImportLookupIds lookupIds, string? type)
{
if (!string.IsNullOrWhiteSpace(type) && lookupIds.AssetKindIds.TryGetValue(type.Trim(), out var id))
@ -955,9 +1062,25 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
return false;
}
private static bool TryResolveSceneReference(IReadOnlyDictionary<string, int> sceneMap, ImportSceneReferenceDto? reference, out int sceneId)
{
if (reference?.BookOrder.HasValue == true && reference.ChapterOrder.HasValue && reference.SceneOrder.HasValue)
{
return sceneMap.TryGetValue(SceneKey(reference.BookOrder.Value, reference.ChapterOrder.Value, reference.SceneOrder.Value), out sceneId);
}
sceneId = 0;
return false;
}
private static string SceneKey(int bookOrder, int chapterOrder, int sceneOrder)
=> $"{bookOrder}/{chapterOrder}/{sceneOrder}";
private static string SceneReferenceKey(ImportSceneReferenceDto? reference)
=> reference is null
? "(missing)"
: $"{reference.BookOrder?.ToString() ?? "?"}/{reference.ChapterOrder?.ToString() ?? "?"}/{reference.SceneOrder?.ToString() ?? "?"}";
private static string Clean(string? value) => (value ?? string.Empty).Trim();
private static string? MetadataLine(string label, string? value)
@ -1010,6 +1133,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
IReadOnlyDictionary<string, int> ThreadTypeIds,
IReadOnlyDictionary<string, int> ThreadStatusIds,
IReadOnlyDictionary<string, int> ThreadEventTypeIds,
IReadOnlyDictionary<string, int> SceneDependencyTypeIds,
IReadOnlyDictionary<string, int> AssetKindIds,
IReadOnlyDictionary<string, int> AssetStateIds,
IReadOnlyDictionary<string, int> AssetEventTypeIds,

View File

@ -11,6 +11,7 @@ public sealed class PlotLineImportPackage
public List<ImportLocationDto> Locations { get; set; } = [];
public List<ImportPlotLineDto> PlotLines { get; set; } = [];
public List<ImportStoryAssetDto> StoryAssets { get; set; } = [];
public List<ImportSceneDependencyDto> SceneDependencies { get; set; } = [];
public List<ImportBookDto> Books { get; set; } = [];
[JsonExtensionData]
@ -132,6 +133,24 @@ public sealed class ImportAssetEventDto
public string? Notes { get; set; }
}
public sealed class ImportSceneDependencyDto
{
public ImportSceneReferenceDto? FromScene { get; set; }
public ImportSceneReferenceDto? ToScene { get; set; }
public string? DependencyType { get; set; }
public string? Strength { get; set; }
public string? Description { get; set; }
public string? Notes { get; set; }
}
public sealed class ImportSceneReferenceDto
{
public string? SceneRef { get; set; }
public int? BookOrder { get; set; }
public int? ChapterOrder { get; set; }
public int? SceneOrder { get; set; }
}
public sealed class ImportChapterDto
{
public decimal ChapterNumber { get; set; }
@ -186,6 +205,7 @@ public sealed class ImportPreview
public IReadOnlyList<string> UnresolvedAssetReferences { get; init; } = [];
public IReadOnlyList<string> UnmatchedAssetOwnerNames { get; init; } = [];
public IReadOnlyList<string> UnmatchedAssetLocationNames { get; init; } = [];
public IReadOnlyList<string> UnresolvedSceneDependencyReferences { get; init; } = [];
public int BookCount => Package?.Books.Count ?? 0;
public int CharacterCount => Package?.Characters.Count ?? 0;
public int LocationCount => Package?.Locations.Count ?? 0;
@ -200,6 +220,8 @@ public sealed class ImportPreview
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 SceneDependencyCount => Package?.SceneDependencies.Count ?? 0;
public int ResolvedSceneDependencyCount => Math.Max(0, SceneDependencyCount - UnresolvedSceneDependencyReferences.Count);
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;
@ -243,6 +265,7 @@ public sealed class ImportResult
public int AssetStatesCreated { get; init; }
public int AssetEventsCreated { get; init; }
public int AssetCustodyEventsCreated { get; init; }
public int SceneDependenciesCreated { get; init; }
public IReadOnlyList<string> SkippedOptionalFields { get; init; } = [];
public IReadOnlyList<string> UnresolvedWarnings { get; init; } = [];
public string? TechnicalDetail { get; init; }

View File

@ -112,6 +112,7 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
builder.AppendLine($"Asset states: {preview.AssetStateCount}");
builder.AppendLine($"Asset events: {preview.AssetEventCount}");
builder.AppendLine($"Asset custody links: {preview.AssetCustodyCount}");
builder.AppendLine($"Scene dependencies: {preview.SceneDependencyCount}");
builder.AppendLine($"Books: {preview.BookCount}");
builder.AppendLine($"Chapters: {preview.ChapterCount}");
builder.AppendLine($"Scenes: {preview.SceneCount}");
@ -126,6 +127,7 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
AppendReportSection(builder, "Unresolved asset references", preview.UnresolvedAssetReferences);
AppendReportSection(builder, "Unmatched asset owners", preview.UnmatchedAssetOwnerNames);
AppendReportSection(builder, "Unmatched asset locations", preview.UnmatchedAssetLocationNames);
AppendReportSection(builder, "Unresolved scene dependency references", preview.UnresolvedSceneDependencyReferences);
return builder.ToString();
}
@ -184,7 +186,8 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
UnresolvedThreadEventReferences = FindUnresolvedThreadEventReferences(package),
UnresolvedAssetReferences = FindUnresolvedAssetReferences(package),
UnmatchedAssetOwnerNames = FindUnmatchedAssetOwnerNames(package),
UnmatchedAssetLocationNames = FindUnmatchedAssetLocationNames(package)
UnmatchedAssetLocationNames = FindUnmatchedAssetLocationNames(package),
UnresolvedSceneDependencyReferences = FindUnresolvedSceneDependencyReferences(package)
};
}
@ -207,10 +210,11 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
ValidateLocations(package, result);
ValidatePlotLines(package, result);
ValidateStoryAssets(package, result);
ValidateSceneDependencies(package, result);
if (string.IsNullOrWhiteSpace(package.PackageVersion))
{
result.Warnings.Add("packageVersion is missing. Version 1.0 is expected for Phase 1D.");
result.Warnings.Add("packageVersion is missing. Version 1.0 is expected for Phase 1F.");
}
else if (package.PackageVersion.Trim() != "1.0")
{
@ -370,9 +374,68 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
result.Warnings.Add($"Story asset location \"{name}\" does not match a top-level imported location.");
}
foreach (var reference in FindUnresolvedSceneDependencyReferences(package))
{
result.Warnings.Add($"Scene dependency reference could not be resolved and will be skipped: {reference}");
}
return result;
}
private static void ValidateSceneDependencies(PlotLineImportPackage package, ImportValidationResult result)
{
foreach (var duplicate in package.SceneDependencies
.Where(dependency => HasSceneOrderReference(dependency.FromScene) && HasSceneOrderReference(dependency.ToScene))
.GroupBy(SceneDependencyKey, StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1))
{
result.Warnings.Add($"Duplicate scene dependency will be imported once: {duplicate.Key}.");
}
for (var index = 0; index < package.SceneDependencies.Count; index++)
{
var dependency = package.SceneDependencies[index];
var label = $"Scene dependency {index + 1}";
if (dependency.FromScene is null)
{
result.Warnings.Add($"{label} is missing fromScene and will be skipped.");
}
else if (!HasSceneOrderReference(dependency.FromScene) && string.IsNullOrWhiteSpace(dependency.FromScene.SceneRef))
{
result.Warnings.Add($"{label} fromScene is missing bookOrder/chapterOrder/sceneOrder.");
}
if (dependency.ToScene is null)
{
result.Warnings.Add($"{label} is missing toScene and will be skipped.");
}
else if (!HasSceneOrderReference(dependency.ToScene) && string.IsNullOrWhiteSpace(dependency.ToScene.SceneRef))
{
result.Warnings.Add($"{label} toScene is missing bookOrder/chapterOrder/sceneOrder.");
}
if (HasSceneOrderReference(dependency.FromScene) && HasSceneOrderReference(dependency.ToScene)
&& SceneReferenceKey(dependency.FromScene!) == SceneReferenceKey(dependency.ToScene!))
{
result.Warnings.Add($"{label} links a scene to itself and will be skipped.");
}
if (!string.IsNullOrWhiteSpace(dependency.DependencyType) && dependency.DependencyType.Trim().Length > 100)
{
result.Warnings.Add($"{label} dependencyType is longer than 100 characters and may not match an existing dependency type.");
}
if (!string.IsNullOrWhiteSpace(dependency.Strength) && !IsSupportedDependencyStrength(dependency.Strength))
{
result.Warnings.Add($"{label} strength \"{dependency.Strength.Trim()}\" is not a recognized hard/soft value. It will be preserved in the description.");
}
ValidateLargeText(result, $"{label} description", dependency.Description);
ValidateLargeText(result, $"{label} notes", dependency.Notes);
}
}
private static void ValidateStoryAssets(PlotLineImportPackage package, ImportValidationResult result)
{
foreach (var group in package.StoryAssets
@ -776,6 +839,33 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
.ToList()!;
}
private static IReadOnlyList<string> FindUnresolvedSceneDependencyReferences(PlotLineImportPackage package)
{
var sceneKeys = package.Books
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => $"{book.SeriesOrder}/{chapter.Order}/{scene.Order}")))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return package.SceneDependencies
.SelectMany((dependency, index) => new[]
{
new
{
Label = $"Dependency {index + 1} fromScene",
Reference = dependency.FromScene
},
new
{
Label = $"Dependency {index + 1} toScene",
Reference = dependency.ToScene
}
})
.Where(x => !HasSceneOrderReference(x.Reference) || !sceneKeys.Contains(SceneReferenceKey(x.Reference!)))
.Select(x => $"{x.Label} -> {SceneReferenceKey(x.Reference)}")
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x)
.ToList();
}
private static bool HasSceneOrderReference(ImportThreadEventDto threadEvent)
=> threadEvent.BookOrder.HasValue && threadEvent.ChapterOrder.HasValue && threadEvent.SceneOrder.HasValue;
@ -794,6 +884,35 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
private static string AssetEventSceneKey(ImportAssetEventDto assetEvent)
=> $"{assetEvent.BookOrder?.ToString() ?? "?"}/{assetEvent.ChapterOrder?.ToString() ?? "?"}/{assetEvent.SceneOrder?.ToString() ?? "?"}";
private static bool HasSceneOrderReference(ImportSceneReferenceDto? reference)
=> reference?.BookOrder.HasValue == true && reference.ChapterOrder.HasValue && reference.SceneOrder.HasValue;
private static string SceneReferenceKey(ImportSceneReferenceDto? reference)
=> reference is null
? "(missing)"
: $"{reference.BookOrder?.ToString() ?? "?"}/{reference.ChapterOrder?.ToString() ?? "?"}/{reference.SceneOrder?.ToString() ?? "?"}";
private static string SceneDependencyKey(ImportSceneDependencyDto dependency)
=> $"{SceneReferenceKey(dependency.FromScene)} -> {SceneReferenceKey(dependency.ToScene)} / {CleanDependencyType(dependency.DependencyType)}";
private static string CleanDependencyType(string? dependencyType)
=> string.IsNullOrWhiteSpace(dependencyType) ? "Must Occur Before" : dependencyType.Trim();
private static bool IsSupportedDependencyStrength(string strength)
{
var normalized = strength.Trim();
return normalized.Equals("hard", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("soft", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("strong", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("weak", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("required", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("optional", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("high", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("medium", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("low", StringComparison.OrdinalIgnoreCase)
|| (int.TryParse(normalized, out var numeric) && numeric is >= 1 and <= 10);
}
private static HashSet<string> CharacterNames(PlotLineImportPackage package)
=> package.Characters
.Where(x => !string.IsNullOrWhiteSpace(x.Name))

View File

@ -21,7 +21,7 @@ public sealed class ImportIndexViewModel
public static string SampleJson => """
{
"packageVersion": "1.0",
"source": "JSON Import Phase 1E sample",
"source": "JSON Import Phase 1F sample",
"project": {
"title": "The Glass Meridian",
"description": "A placeholder two-book fantasy outline.",
@ -148,6 +148,44 @@ public sealed class ImportIndexViewModel
]
}
],
"sceneDependencies": [
{
"fromScene": { "bookOrder": 1, "chapterOrder": 1, "sceneOrder": 1 },
"toScene": { "bookOrder": 1, "chapterOrder": 1, "sceneOrder": 2 },
"dependencyType": "Requires",
"strength": "Hard",
"description": "Mara needs to find the arcade door before choosing to descend.",
"notes": "Opening cause and effect."
},
{
"fromScene": { "bookOrder": 1, "chapterOrder": 1, "sceneOrder": 2 },
"toScene": { "bookOrder": 1, "chapterOrder": 2, "sceneOrder": 1 },
"dependencyType": "Reveals",
"strength": "Strong",
"description": "The descent sets up the first view of Lantern Court."
},
{
"fromScene": { "bookOrder": 1, "chapterOrder": 2, "sceneOrder": 2 },
"toScene": { "bookOrder": 2, "chapterOrder": 1, "sceneOrder": 1 },
"dependencyType": "Follows",
"strength": "Medium",
"description": "Ilen's bargain motivates the later departure."
},
{
"fromScene": { "bookOrder": 2, "chapterOrder": 1, "sceneOrder": 2 },
"toScene": { "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 1 },
"dependencyType": "Reveals",
"strength": "Hard",
"description": "The damaged signal plate must be recovered before Mara can translate it."
},
{
"fromScene": { "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 1 },
"toScene": { "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 2 },
"dependencyType": "Blocks",
"strength": "7",
"description": "The warning turns the causeway crossing into a race."
}
],
"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 1E package, preview it, confirm the summary, then import a new project.</p>
<p class="lead-text">Paste or upload a Phase 1F 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,15 @@
</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>, 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>, 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>Scene links</h3>
<p><code>sceneDependencies</code> use <code>fromScene</code> and <code>toScene</code> objects. Link scenes with <code>bookOrder</code>, <code>chapterOrder</code>, and <code>sceneOrder</code>; <code>sceneRef</code> is optional future-friendly text. <code>dependencyType</code>, <code>strength</code>, <code>description</code>, and <code>notes</code> are optional. Warnings can be acknowledged; errors block import. Unknown fields are ignored safely.</p>
</div>
<div>
<h3>Not imported yet</h3>
<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>
<p>No AI extraction, Word/text parsing, manuscript parsing, metrics import, continuity warning import, relationship graphs, merge/update, or export logic.</p>
</div>
</div>
</details>
@ -97,6 +101,7 @@
<span><strong>@preview.ResolvedAssetStateCount</strong> asset states</span>
<span><strong>@preview.ResolvedAssetEventCount</strong> asset events</span>
<span><strong>@preview.ResolvedAssetCustodyCount</strong> custody links</span>
<span><strong>@preview.ResolvedSceneDependencyCount</strong> dependencies</span>
</div>
</div>
@ -135,7 +140,7 @@
@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())
@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())
{
<div class="import-mapping-summary">
@if (preview.UnmatchedPovNames.Any())
@ -166,6 +171,10 @@
{
<span>Unmatched asset locations: @string.Join(", ", preview.UnmatchedAssetLocationNames)</span>
}
@if (preview.UnresolvedSceneDependencyReferences.Any())
{
<span>Unresolved dependencies: @string.Join(", ", preview.UnresolvedSceneDependencyReferences)</span>
}
</div>
}
@ -229,6 +238,37 @@
</div>
}
@if (preview.Package.SceneDependencies.Any())
{
<div class="import-tree mt-3">
@foreach (var group in preview.Package.SceneDependencies.Select((dependency, index) => new { Dependency = dependency, Index = index + 1 }).GroupBy(x => $"{x.Dependency.FromScene?.BookOrder?.ToString() ?? "?"}/{x.Dependency.FromScene?.ChapterOrder?.ToString() ?? "?"}").OrderBy(x => x.Key))
{
<details class="import-tree-book">
<summary>
<strong>Dependencies from @group.Key</strong>
<span>@group.Count() links</span>
</summary>
<ul>
@foreach (var item in group.Take(12))
{
var dependency = item.Dependency;
var fromScene = $"{dependency.FromScene?.BookOrder?.ToString() ?? "?"}/{dependency.FromScene?.ChapterOrder?.ToString() ?? "?"}/{dependency.FromScene?.SceneOrder?.ToString() ?? "?"}";
var toScene = $"{dependency.ToScene?.BookOrder?.ToString() ?? "?"}/{dependency.ToScene?.ChapterOrder?.ToString() ?? "?"}/{dependency.ToScene?.SceneOrder?.ToString() ?? "?"}";
<li>
<strong>@fromScene -> @toScene</strong>
<span class="import-scene-meta">@((dependency.DependencyType ?? "Dependency")) / @(dependency.Strength ?? "soft")</span>
</li>
}
@if (group.Count() > 12)
{
<li class="muted">Showing first 12 links in this group.</li>
}
</ul>
</details>
}
</div>
}
@if (preview.Package.StoryAssets.Any())
{
<div class="import-tree mt-3">
@ -249,7 +289,7 @@
<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, <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>
<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, <strong>@preview.ResolvedAssetCustodyCount</strong> custody links, and <strong>@preview.ResolvedSceneDependencyCount</strong> scene dependencies.</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

@ -30,6 +30,7 @@
<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>
<span><strong>@Model.SceneDependenciesCreated</strong> scene dependencies created</span>
</div>
@if (Model.SkippedOptionalFields.Any())