Importer Phase 1C
This commit is contained in:
parent
29d3da9d4e
commit
a39b5cb7d8
@ -13,7 +13,7 @@ public interface IImportRepository
|
||||
|
||||
public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository
|
||||
{
|
||||
private const string ImportMarker = "Imported via JSON Import Phase 1B";
|
||||
private const string ImportMarker = "Imported via JSON Import Phase 1D";
|
||||
|
||||
public async Task<bool> ProjectTitleExistsAsync(string projectTitle)
|
||||
{
|
||||
@ -40,7 +40,15 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
||||
var booksCreated = 0;
|
||||
var chaptersCreated = 0;
|
||||
var scenesCreated = 0;
|
||||
var charactersCreated = 0;
|
||||
var locationsCreated = 0;
|
||||
var appearancesCreated = 0;
|
||||
var plotLinesCreated = 0;
|
||||
var plotThreadsCreated = 0;
|
||||
var threadEventsCreated = 0;
|
||||
var skippedFields = GetSkippedOptionalFields(package);
|
||||
var unresolvedWarnings = GetUnresolvedWarnings(package);
|
||||
var sceneMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var projectName = string.IsNullOrWhiteSpace(importedProjectTitle)
|
||||
? Clean(package.Project.Title)
|
||||
: Clean(importedProjectTitle);
|
||||
@ -59,6 +67,93 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
var characterMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var character in package.Characters)
|
||||
{
|
||||
var characterName = Clean(DisplayName(character.DisplayName, character.Name));
|
||||
if (characterName.Length == 0 || characterMap.ContainsKey(character.Name.Trim()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var characterId = await connection.QuerySingleAsync<int>(
|
||||
"dbo.Character_Save",
|
||||
new
|
||||
{
|
||||
CharacterID = 0,
|
||||
ProjectID = projectId,
|
||||
CharacterName = characterName,
|
||||
ShortName = Truncate(character.ShortName, 100),
|
||||
Sex = (string?)null,
|
||||
BirthDate = (DateTime?)null,
|
||||
AgeAtSeriesStart = (int?)null,
|
||||
AgeReferenceSceneID = (int?)null,
|
||||
Height = (string?)null,
|
||||
EyeColour = (string?)null,
|
||||
DefaultDescription = CombineText(
|
||||
character.Description,
|
||||
MetadataLine("Role", character.Role),
|
||||
MetadataLine("Importance", character.Importance?.ToString()),
|
||||
character.Notes,
|
||||
ImportMarker)
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
characterMap[character.Name.Trim()] = characterId;
|
||||
charactersCreated++;
|
||||
}
|
||||
|
||||
var locationMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var location in package.Locations)
|
||||
{
|
||||
var locationName = Clean(DisplayName(location.DisplayName, location.Name));
|
||||
if (locationName.Length == 0 || locationMap.ContainsKey(location.Name.Trim()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var locationId = await connection.QuerySingleAsync<int>(
|
||||
"dbo.Location_Save",
|
||||
new
|
||||
{
|
||||
LocationID = 0,
|
||||
ProjectID = projectId,
|
||||
ParentLocationID = (int?)null,
|
||||
LocationName = locationName,
|
||||
LocationTypeID = LookupLocationTypeId(lookupIds, location.Type),
|
||||
Description = CombineText(location.Description, location.Notes, ImportMarker)
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
locationMap[location.Name.Trim()] = locationId;
|
||||
locationsCreated++;
|
||||
}
|
||||
|
||||
foreach (var location in package.Locations.Where(x => !string.IsNullOrWhiteSpace(x.ParentLocationName)))
|
||||
{
|
||||
if (!locationMap.TryGetValue(location.Name.Trim(), out var locationId)
|
||||
|| !locationMap.TryGetValue(location.ParentLocationName!.Trim(), out var parentLocationId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await connection.QuerySingleAsync<int>(
|
||||
"dbo.Location_Save",
|
||||
new
|
||||
{
|
||||
LocationID = locationId,
|
||||
ProjectID = projectId,
|
||||
ParentLocationID = parentLocationId,
|
||||
LocationName = Clean(DisplayName(location.DisplayName, location.Name)),
|
||||
LocationTypeID = LookupLocationTypeId(lookupIds, location.Type),
|
||||
Description = CombineText(location.Description, location.Notes, ImportMarker)
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
foreach (var book in package.Books.OrderBy(x => x.SeriesOrder))
|
||||
{
|
||||
var bookId = await connection.QuerySingleAsync<int>(
|
||||
@ -98,7 +193,9 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
||||
|
||||
foreach (var scene in chapter.Scenes.OrderBy(x => x.Order))
|
||||
{
|
||||
await connection.QuerySingleAsync<int>(
|
||||
var primaryLocationId = ResolveId(locationMap, scene.LocationName);
|
||||
var povCharacterId = ResolveId(characterMap, scene.PovCharacterName);
|
||||
var sceneId = await connection.QuerySingleAsync<int>(
|
||||
"dbo.Scene_Save",
|
||||
new
|
||||
{
|
||||
@ -108,8 +205,8 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
||||
SceneTitle = Clean(scene.Title),
|
||||
Summary = CombineText(
|
||||
scene.Summary,
|
||||
MetadataLine("POV", scene.PovCharacterName),
|
||||
MetadataLine("Location", scene.LocationName),
|
||||
povCharacterId.HasValue ? null : MetadataLine("POV", scene.PovCharacterName),
|
||||
primaryLocationId.HasValue ? null : MetadataLine("Location", scene.LocationName),
|
||||
scene.Notes,
|
||||
ImportMarker),
|
||||
TimeModeID = lookupIds.TimeModeID,
|
||||
@ -121,11 +218,139 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
||||
TimeConfidenceID = lookupIds.TimeConfidenceID,
|
||||
ScenePurposeNotes = CombineText(scene.PurposeText),
|
||||
SceneOutcomeNotes = CombineText(scene.OutcomeText),
|
||||
RevisionStatusID = lookupIds.RevisionStatusID
|
||||
RevisionStatusID = lookupIds.RevisionStatusID,
|
||||
PrimaryLocationID = primaryLocationId
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
if (povCharacterId.HasValue)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE dbo.Scenes SET POVCharacterID = @POVCharacterID, UpdatedDate = SYSUTCDATETIME() WHERE SceneID = @SceneID;",
|
||||
new { SceneID = sceneId, POVCharacterID = povCharacterId.Value },
|
||||
transaction);
|
||||
}
|
||||
|
||||
foreach (var appearance in BuildSceneAppearances(scene, povCharacterId, characterMap))
|
||||
{
|
||||
await connection.QuerySingleAsync<int>(
|
||||
"dbo.SceneCharacter_Save",
|
||||
new
|
||||
{
|
||||
SceneCharacterID = 0,
|
||||
SceneID = sceneId,
|
||||
CharacterID = appearance.CharacterId,
|
||||
RoleInSceneTypeID = ResolveRoleTypeId(lookupIds, appearance.RoleInScene),
|
||||
PresenceTypeID = ResolvePresenceTypeId(lookupIds, appearance.RoleInScene),
|
||||
LocationID = primaryLocationId,
|
||||
EntryLocationID = (int?)null,
|
||||
ExitLocationID = (int?)null,
|
||||
AppearanceNotes = CombineText(
|
||||
MetadataLine("Imported role", appearance.RoleInScene),
|
||||
MetadataLine("Importance", appearance.Importance?.ToString()),
|
||||
appearance.Notes),
|
||||
OutfitDescription = (string?)null,
|
||||
PhysicalCondition = (string?)null,
|
||||
EmotionalState = (string?)null,
|
||||
KnowledgeNotes = (string?)null
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
appearancesCreated++;
|
||||
}
|
||||
scenesCreated++;
|
||||
sceneMap[SceneKey(book.SeriesOrder, chapter.Order, scene.Order)] = sceneId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var plotLine in package.PlotLines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plotLine.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var plotLineId = await connection.QuerySingleAsync<int>(
|
||||
"dbo.PlotLine_Save",
|
||||
new
|
||||
{
|
||||
PlotLineID = 0,
|
||||
ProjectID = projectId,
|
||||
BookID = (int?)null,
|
||||
PlotLineName = Clean(DisplayName(plotLine.DisplayName, plotLine.Name)),
|
||||
PlotLineTypeID = ResolvePlotLineTypeId(lookupIds, plotLine.Type),
|
||||
Description = CombineText(
|
||||
plotLine.Description,
|
||||
MetadataLine("Type", plotLine.Type),
|
||||
MetadataLine("Importance", plotLine.Importance?.ToString()),
|
||||
plotLine.Notes,
|
||||
ImportMarker),
|
||||
ParentPlotLineID = (int?)null,
|
||||
SortOrder = (int?)null,
|
||||
Colour = "#2f6f63",
|
||||
IsMainPlot = string.Equals(plotLine.Type?.Trim(), "Main Plot", StringComparison.OrdinalIgnoreCase),
|
||||
IsVisibleOnTimeline = true
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
plotLinesCreated++;
|
||||
|
||||
foreach (var thread in plotLine.Threads)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(thread.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var threadId = await connection.QuerySingleAsync<int>(
|
||||
"dbo.PlotThread_Save",
|
||||
new
|
||||
{
|
||||
PlotThreadID = 0,
|
||||
PlotLineID = plotLineId,
|
||||
ThreadTitle = Clean(DisplayName(thread.DisplayName, thread.Name)),
|
||||
ThreadTypeID = ResolveThreadTypeId(lookupIds, thread.Type),
|
||||
ThreadStatusID = ResolveThreadStatusId(lookupIds, thread.Status),
|
||||
Importance = ClampImportance(thread.Importance),
|
||||
Summary = CombineText(thread.Description, MetadataLine("Status", thread.Status), thread.Notes, ImportMarker),
|
||||
IntroducedSceneID = (int?)null,
|
||||
PlannedResolutionSceneID = (int?)null,
|
||||
ActualResolutionSceneID = (int?)null
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
plotThreadsCreated++;
|
||||
|
||||
var importedEventSceneIds = new HashSet<int>();
|
||||
foreach (var threadEvent in thread.Events)
|
||||
{
|
||||
if (!TryResolveThreadEventScene(sceneMap, threadEvent, out var sceneId) || importedEventSceneIds.Contains(sceneId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await connection.QuerySingleAsync<int>(
|
||||
"dbo.ThreadEvent_Save",
|
||||
new
|
||||
{
|
||||
ThreadEventID = 0,
|
||||
PlotThreadID = threadId,
|
||||
SceneID = sceneId,
|
||||
EventTypeID = ResolveThreadEventTypeId(lookupIds, threadEvent.EventType),
|
||||
EventTitle = Truncate(Clean(threadEvent.Title).Length == 0 ? Clean(threadEvent.Summary) : threadEvent.Title, 200) ?? "Imported thread event",
|
||||
EventDescription = CombineText(
|
||||
threadEvent.Summary,
|
||||
MetadataLine("Event type", threadEvent.EventType),
|
||||
MetadataLine("Impact", threadEvent.Impact),
|
||||
MetadataLine("Importance", threadEvent.Importance?.ToString()),
|
||||
threadEvent.Notes,
|
||||
ImportMarker)
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
importedEventSceneIds.Add(sceneId);
|
||||
threadEventsCreated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -139,7 +364,14 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
||||
BooksCreated = booksCreated,
|
||||
ChaptersCreated = chaptersCreated,
|
||||
ScenesCreated = scenesCreated,
|
||||
CharactersCreated = charactersCreated,
|
||||
LocationsCreated = locationsCreated,
|
||||
SceneCharacterAppearancesCreated = appearancesCreated,
|
||||
PlotLinesCreated = plotLinesCreated,
|
||||
PlotThreadsCreated = plotThreadsCreated,
|
||||
ThreadEventsCreated = threadEventsCreated,
|
||||
SkippedOptionalFields = skippedFields,
|
||||
UnresolvedWarnings = unresolvedWarnings,
|
||||
Message = "Import complete."
|
||||
};
|
||||
}
|
||||
@ -162,12 +394,44 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
||||
"SELECT TOP (1) TimeConfidenceID FROM dbo.TimeConfidences WHERE TimeConfidenceName = N'Unknown' ORDER BY SortOrder;",
|
||||
transaction: transaction);
|
||||
|
||||
var locationTypes = (await connection.QueryAsync<ImportLookupRow>(
|
||||
"SELECT LocationTypeID AS LookupID, TypeName FROM dbo.LocationTypes WHERE IsActive = 1;",
|
||||
transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
|
||||
var roleTypes = (await connection.QueryAsync<ImportLookupRow>(
|
||||
"SELECT CharacterRoleInSceneTypeID AS LookupID, TypeName FROM dbo.CharacterRoleInSceneTypes WHERE IsActive = 1;",
|
||||
transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
|
||||
var presenceTypes = (await connection.QueryAsync<ImportLookupRow>(
|
||||
"SELECT PresenceTypeID AS LookupID, TypeName FROM dbo.PresenceTypes WHERE IsActive = 1;",
|
||||
transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
|
||||
var plotLineTypes = (await connection.QueryAsync<ImportLookupRow>(
|
||||
"SELECT PlotLineTypeID AS LookupID, TypeName FROM dbo.PlotLineTypes WHERE IsActive = 1;",
|
||||
transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
|
||||
var threadTypes = (await connection.QueryAsync<ImportLookupRow>(
|
||||
"SELECT ThreadTypeID AS LookupID, TypeName FROM dbo.ThreadTypes WHERE IsActive = 1;",
|
||||
transaction: transaction)).ToDictionary(x => x.TypeName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
|
||||
var threadStatuses = (await connection.QueryAsync<ImportStatusLookupRow>(
|
||||
"SELECT ThreadStatusID AS LookupID, StatusName FROM dbo.ThreadStatuses WHERE IsActive = 1;",
|
||||
transaction: transaction)).ToDictionary(x => x.StatusName, x => x.LookupID, StringComparer.OrdinalIgnoreCase);
|
||||
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);
|
||||
|
||||
if (!revisionStatusId.HasValue || !timeModeId.HasValue || !timeConfidenceId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Import lookup values are missing. Run the core database script before importing.");
|
||||
}
|
||||
|
||||
return new ImportLookupIds(revisionStatusId.Value, timeModeId.Value, timeConfidenceId.Value);
|
||||
return new ImportLookupIds(
|
||||
revisionStatusId.Value,
|
||||
timeModeId.Value,
|
||||
timeConfidenceId.Value,
|
||||
locationTypes,
|
||||
roleTypes,
|
||||
presenceTypes,
|
||||
plotLineTypes,
|
||||
threadTypes,
|
||||
threadStatuses,
|
||||
threadEventTypes);
|
||||
}
|
||||
|
||||
private static string FormatBookTitle(ImportBookDto book)
|
||||
@ -182,19 +446,233 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
||||
private static IReadOnlyList<string> GetSkippedOptionalFields(PlotLineImportPackage package)
|
||||
{
|
||||
var skipped = new List<string>();
|
||||
if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName)))))
|
||||
var characterNames = package.Characters
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
||||
.Select(x => x.Name.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var locationNames = package.Locations
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
||||
.Select(x => x.Name.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName) && !characterNames.Contains(scene.PovCharacterName.Trim())))))
|
||||
{
|
||||
skipped.Add("POV character names were stored as plain text in scene summaries; Character records were not created.");
|
||||
}
|
||||
|
||||
if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName)))))
|
||||
if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName) && !locationNames.Contains(scene.LocationName.Trim())))))
|
||||
{
|
||||
skipped.Add("Location names were stored as plain text in scene summaries; Location records were not created.");
|
||||
}
|
||||
|
||||
if (package.PlotLines.Any(plotLine => plotLine.Importance.HasValue))
|
||||
{
|
||||
skipped.Add("Plot line importance was stored as text in plot line descriptions; PlotLine records do not have an importance column.");
|
||||
}
|
||||
|
||||
if (package.PlotLines.Any(plotLine => plotLine.Threads.Any(thread => thread.Events.Any(threadEvent => threadEvent.Importance.HasValue || !string.IsNullOrWhiteSpace(threadEvent.Impact)))))
|
||||
{
|
||||
skipped.Add("Thread event impact and importance were stored in event descriptions.");
|
||||
}
|
||||
|
||||
return skipped;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetUnresolvedWarnings(PlotLineImportPackage package)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
var characterNames = package.Characters
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
||||
.Select(x => x.Name.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var locationNames = package.Locations
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
||||
.Select(x => x.Name.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.PovCharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name)).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
warnings.Add($"Unmatched POV character: {name}");
|
||||
}
|
||||
|
||||
foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).Select(scene => scene.LocationName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !locationNames.Contains(name)).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
warnings.Add($"Unmatched scene location: {name}");
|
||||
}
|
||||
|
||||
foreach (var name in package.Books.SelectMany(book => book.Chapters).SelectMany(chapter => chapter.Scenes).SelectMany(scene => scene.SceneCharacters).Select(sceneCharacter => sceneCharacter.CharacterName?.Trim()).Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name)).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
warnings.Add($"Unmatched scene character: {name}");
|
||||
}
|
||||
|
||||
var sceneKeys = package.Books
|
||||
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => SceneKey(book.SeriesOrder, chapter.Order, scene.Order))))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var reference in package.PlotLines.SelectMany(plotLine => plotLine.Threads.SelectMany(thread => thread.Events.Select((threadEvent, index) => new
|
||||
{
|
||||
PlotLineName = DisplayName(plotLine.DisplayName, plotLine.Name),
|
||||
ThreadName = DisplayName(thread.DisplayName, thread.Name),
|
||||
EventTitle = string.IsNullOrWhiteSpace(threadEvent.Title) ? $"Event {index + 1}" : threadEvent.Title.Trim(),
|
||||
Event = threadEvent
|
||||
})))
|
||||
.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.PlotLineName} / {x.ThreadName} / {x.EventTitle}")
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
warnings.Add($"Unresolved thread event scene reference: {reference}");
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private static IEnumerable<ImportAppearance> BuildSceneAppearances(ImportSceneDto scene, int? povCharacterId, IReadOnlyDictionary<string, int> characterMap)
|
||||
{
|
||||
var appearances = new Dictionary<int, ImportAppearance>();
|
||||
|
||||
foreach (var sceneCharacter in scene.SceneCharacters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sceneCharacter.CharacterName)
|
||||
|| !characterMap.TryGetValue(sceneCharacter.CharacterName.Trim(), out var characterId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
appearances[characterId] = new ImportAppearance(characterId, sceneCharacter.RoleInScene, sceneCharacter.Importance, sceneCharacter.Notes);
|
||||
}
|
||||
|
||||
if (povCharacterId.HasValue && !appearances.ContainsKey(povCharacterId.Value))
|
||||
{
|
||||
appearances[povCharacterId.Value] = new ImportAppearance(povCharacterId.Value, "POV", null, "Auto-added from povCharacterName.");
|
||||
}
|
||||
|
||||
return appearances.Values;
|
||||
}
|
||||
|
||||
private static int? ResolveId(IReadOnlyDictionary<string, int> map, string? name)
|
||||
=> !string.IsNullOrWhiteSpace(name) && map.TryGetValue(name.Trim(), out var id) ? id : null;
|
||||
|
||||
private static int? LookupLocationTypeId(ImportLookupIds lookupIds, string? type)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(type) && lookupIds.LocationTypeIds.TryGetValue(type.Trim(), out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
return lookupIds.LocationTypeIds.TryGetValue("Other", out var otherId) ? otherId : null;
|
||||
}
|
||||
|
||||
private static int? ResolveRoleTypeId(ImportLookupIds lookupIds, string? role)
|
||||
{
|
||||
var normalized = Clean(role);
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
normalized = "Present";
|
||||
}
|
||||
|
||||
var roleName = normalized.Equals("POV", StringComparison.OrdinalIgnoreCase)
|
||||
? "POV Character"
|
||||
: normalized.Equals("Present", StringComparison.OrdinalIgnoreCase)
|
||||
? "Main Participant"
|
||||
: normalized.Equals("Supporting", StringComparison.OrdinalIgnoreCase)
|
||||
? "Supporting Participant"
|
||||
: normalized.Equals("Mentioned", StringComparison.OrdinalIgnoreCase)
|
||||
? "Mentioned Only"
|
||||
: normalized.Equals("Antagonist", StringComparison.OrdinalIgnoreCase)
|
||||
? "Main Participant"
|
||||
: normalized;
|
||||
|
||||
if (lookupIds.RoleTypeIds.TryGetValue(roleName, out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
return lookupIds.RoleTypeIds.TryGetValue("Supporting Participant", out var supportingId) ? supportingId : null;
|
||||
}
|
||||
|
||||
private static int? ResolvePresenceTypeId(ImportLookupIds lookupIds, string? role)
|
||||
{
|
||||
var normalized = Clean(role);
|
||||
var presenceName = normalized.Equals("Mentioned", StringComparison.OrdinalIgnoreCase)
|
||||
? "Mentioned Only"
|
||||
: normalized.Equals("Background", StringComparison.OrdinalIgnoreCase)
|
||||
? "Present"
|
||||
: normalized.Equals("POV", StringComparison.OrdinalIgnoreCase) || normalized.Equals("Antagonist", StringComparison.OrdinalIgnoreCase) || normalized.Equals("Supporting", StringComparison.OrdinalIgnoreCase)
|
||||
? "Present"
|
||||
: normalized.Length == 0 ? "Present" : normalized;
|
||||
|
||||
if (lookupIds.PresenceTypeIds.TryGetValue(presenceName, out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
return lookupIds.PresenceTypeIds.TryGetValue("Present", out var presentId) ? presentId : null;
|
||||
}
|
||||
|
||||
private static int ResolvePlotLineTypeId(ImportLookupIds lookupIds, string? type)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(type) && lookupIds.PlotLineTypeIds.TryGetValue(type.Trim(), out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
return lookupIds.PlotLineTypeIds.TryGetValue("Side Plot", out var sidePlotId)
|
||||
? sidePlotId
|
||||
: lookupIds.PlotLineTypeIds.Values.First();
|
||||
}
|
||||
|
||||
private static int ResolveThreadTypeId(ImportLookupIds lookupIds, string? type)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(type) && lookupIds.ThreadTypeIds.TryGetValue(type.Trim(), out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
return lookupIds.ThreadTypeIds.TryGetValue("Question", out var questionId)
|
||||
? questionId
|
||||
: lookupIds.ThreadTypeIds.Values.First();
|
||||
}
|
||||
|
||||
private static int ResolveThreadStatusId(ImportLookupIds lookupIds, string? status)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(status) && lookupIds.ThreadStatusIds.TryGetValue(status.Trim(), out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
return lookupIds.ThreadStatusIds.TryGetValue("Planned", out var plannedId)
|
||||
? plannedId
|
||||
: lookupIds.ThreadStatusIds.Values.First();
|
||||
}
|
||||
|
||||
private static int ResolveThreadEventTypeId(ImportLookupIds lookupIds, string? eventType)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(eventType) && lookupIds.ThreadEventTypeIds.TryGetValue(eventType.Trim(), out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
return lookupIds.ThreadEventTypeIds.TryGetValue("Mentioned", out var mentionedId)
|
||||
? mentionedId
|
||||
: lookupIds.ThreadEventTypeIds.Values.First();
|
||||
}
|
||||
|
||||
private static int ClampImportance(int? importance)
|
||||
=> Math.Clamp(importance ?? 5, 1, 10);
|
||||
|
||||
private static bool TryResolveThreadEventScene(IReadOnlyDictionary<string, int> sceneMap, ImportThreadEventDto threadEvent, out int sceneId)
|
||||
{
|
||||
if (threadEvent.BookOrder.HasValue && threadEvent.ChapterOrder.HasValue && threadEvent.SceneOrder.HasValue)
|
||||
{
|
||||
return sceneMap.TryGetValue(SceneKey(threadEvent.BookOrder.Value, threadEvent.ChapterOrder.Value, threadEvent.SceneOrder.Value), out sceneId);
|
||||
}
|
||||
|
||||
sceneId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string SceneKey(int bookOrder, int chapterOrder, int sceneOrder)
|
||||
=> $"{bookOrder}/{chapterOrder}/{sceneOrder}";
|
||||
|
||||
private static string Clean(string? value) => (value ?? string.Empty).Trim();
|
||||
|
||||
private static string? MetadataLine(string label, string? value)
|
||||
@ -233,5 +711,32 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private sealed record ImportLookupIds(int RevisionStatusID, int TimeModeID, int TimeConfidenceID);
|
||||
private static string DisplayName(string? displayName, string fallback)
|
||||
=> string.IsNullOrWhiteSpace(displayName) ? fallback : displayName;
|
||||
|
||||
private sealed record ImportLookupIds(
|
||||
int RevisionStatusID,
|
||||
int TimeModeID,
|
||||
int TimeConfidenceID,
|
||||
IReadOnlyDictionary<string, int> LocationTypeIds,
|
||||
IReadOnlyDictionary<string, int> RoleTypeIds,
|
||||
IReadOnlyDictionary<string, int> PresenceTypeIds,
|
||||
IReadOnlyDictionary<string, int> PlotLineTypeIds,
|
||||
IReadOnlyDictionary<string, int> ThreadTypeIds,
|
||||
IReadOnlyDictionary<string, int> ThreadStatusIds,
|
||||
IReadOnlyDictionary<string, int> ThreadEventTypeIds);
|
||||
|
||||
private sealed record ImportAppearance(int CharacterId, string? RoleInScene, int? Importance, string? Notes);
|
||||
|
||||
private sealed class ImportLookupRow
|
||||
{
|
||||
public int LookupID { get; set; }
|
||||
public string TypeName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class ImportStatusLookupRow
|
||||
{
|
||||
public int LookupID { get; set; }
|
||||
public string StatusName { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,10 @@ public sealed class PlotLineImportPackage
|
||||
public string? PackageVersion { get; set; }
|
||||
public string? Source { get; set; }
|
||||
public ImportProjectDto Project { get; set; } = new();
|
||||
public List<ImportCharacterDto> Characters { get; set; } = [];
|
||||
public List<ImportLocationDto> Locations { get; set; } = [];
|
||||
public List<ImportPlotLineDto> PlotLines { get; set; } = [];
|
||||
public List<ImportStoryAssetDto> StoryAssets { get; set; } = [];
|
||||
public List<ImportBookDto> Books { get; set; } = [];
|
||||
|
||||
[JsonExtensionData]
|
||||
@ -31,6 +35,103 @@ public sealed class ImportBookDto
|
||||
public List<ImportChapterDto> Chapters { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ImportCharacterDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
public string? ShortName { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public int? Importance { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ImportLocationDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public string? ParentLocationName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ImportPlotLineDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public int? Importance { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<ImportPlotThreadDto> Threads { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ImportPlotThreadDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int? Importance { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<ImportThreadEventDto> Events { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ImportThreadEventDto
|
||||
{
|
||||
public string? SceneRef { get; set; }
|
||||
public int? BookOrder { get; set; }
|
||||
public int? ChapterOrder { get; set; }
|
||||
public int? SceneOrder { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? EventType { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public string? Impact { get; set; }
|
||||
public int? Importance { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ImportStoryAssetDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public int? Importance { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? InitialOwnerCharacterName { get; set; }
|
||||
public string? InitialLocationName { get; set; }
|
||||
public List<ImportAssetStateDto> States { get; set; } = [];
|
||||
public List<ImportAssetEventDto> Events { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ImportAssetStateDto
|
||||
{
|
||||
public string? SceneRef { get; set; }
|
||||
public int? BookOrder { get; set; }
|
||||
public int? ChapterOrder { get; set; }
|
||||
public int? SceneOrder { get; set; }
|
||||
public string? StateName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ImportAssetEventDto
|
||||
{
|
||||
public string? SceneRef { get; set; }
|
||||
public int? BookOrder { get; set; }
|
||||
public int? ChapterOrder { get; set; }
|
||||
public int? SceneOrder { get; set; }
|
||||
public string? EventType { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public string? CharacterName { get; set; }
|
||||
public string? LocationName { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ImportChapterDto
|
||||
{
|
||||
public decimal ChapterNumber { get; set; }
|
||||
@ -53,6 +154,15 @@ public sealed class ImportSceneDto
|
||||
public string? PurposeText { get; set; }
|
||||
public string? OutcomeText { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<ImportSceneCharacterDto> SceneCharacters { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ImportSceneCharacterDto
|
||||
{
|
||||
public string CharacterName { get; set; } = string.Empty;
|
||||
public string? RoleInScene { get; set; }
|
||||
public int? Importance { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ImportValidationResult
|
||||
@ -69,9 +179,34 @@ public sealed class ImportPreview
|
||||
public bool HasDuplicateProjectTitle { get; init; }
|
||||
public string? DuplicateImportProjectTitle { get; init; }
|
||||
public IReadOnlyList<ImportBookPreview> BookPreviews { get; init; } = [];
|
||||
public IReadOnlyList<string> UnmatchedPovNames { get; init; } = [];
|
||||
public IReadOnlyList<string> UnmatchedLocationNames { get; init; } = [];
|
||||
public IReadOnlyList<string> UnmatchedSceneCharacterNames { get; init; } = [];
|
||||
public IReadOnlyList<string> UnresolvedThreadEventReferences { get; init; } = [];
|
||||
public IReadOnlyList<string> UnresolvedAssetReferences { get; init; } = [];
|
||||
public IReadOnlyList<string> UnmatchedAssetOwnerNames { get; init; } = [];
|
||||
public IReadOnlyList<string> UnmatchedAssetLocationNames { get; init; } = [];
|
||||
public int BookCount => Package?.Books.Count ?? 0;
|
||||
public int CharacterCount => Package?.Characters.Count ?? 0;
|
||||
public int LocationCount => Package?.Locations.Count ?? 0;
|
||||
public int PlotLineCount => Package?.PlotLines.Count ?? 0;
|
||||
public int PlotThreadCount => Package?.PlotLines.Sum(plotLine => plotLine.Threads.Count) ?? 0;
|
||||
public int ThreadEventCount => Package?.PlotLines.Sum(plotLine => plotLine.Threads.Sum(thread => thread.Events.Count)) ?? 0;
|
||||
public int ResolvedThreadEventCount => Math.Max(0, ThreadEventCount - UnresolvedThreadEventReferences.Count);
|
||||
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 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 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;
|
||||
|
||||
private static int AutoPovAppearanceCount(ImportSceneDto scene)
|
||||
=> !string.IsNullOrWhiteSpace(scene.PovCharacterName)
|
||||
&& scene.SceneCharacters.All(x => !string.Equals(x.CharacterName?.Trim(), scene.PovCharacterName.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
? 1
|
||||
: 0;
|
||||
}
|
||||
|
||||
public sealed class ImportBookPreview
|
||||
@ -96,7 +231,18 @@ public sealed class ImportResult
|
||||
public int BooksCreated { get; init; }
|
||||
public int ChaptersCreated { get; init; }
|
||||
public int ScenesCreated { get; init; }
|
||||
public int CharactersCreated { get; init; }
|
||||
public int LocationsCreated { get; init; }
|
||||
public int SceneCharacterAppearancesCreated { get; init; }
|
||||
public int PlotLinesCreated { get; init; }
|
||||
public int PlotThreadsCreated { get; init; }
|
||||
public int ThreadEventsCreated { get; init; }
|
||||
public int StoryAssetsCreated { get; init; }
|
||||
public int AssetStatesCreated { get; init; }
|
||||
public int AssetEventsCreated { get; init; }
|
||||
public int AssetCustodyEventsCreated { get; init; }
|
||||
public IReadOnlyList<string> SkippedOptionalFields { get; init; } = [];
|
||||
public IReadOnlyList<string> UnresolvedWarnings { get; init; } = [];
|
||||
public string? TechnicalDetail { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@ -103,12 +103,22 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
|
||||
builder.AppendLine($"Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine($"Project: {preview.Package?.Project.Title ?? "(unreadable)"}");
|
||||
builder.AppendLine($"Characters: {preview.CharacterCount}");
|
||||
builder.AppendLine($"Locations: {preview.LocationCount}");
|
||||
builder.AppendLine($"Plot lines: {preview.PlotLineCount}");
|
||||
builder.AppendLine($"Plot threads: {preview.PlotThreadCount}");
|
||||
builder.AppendLine($"Thread events: {preview.ThreadEventCount}");
|
||||
builder.AppendLine($"Books: {preview.BookCount}");
|
||||
builder.AppendLine($"Chapters: {preview.ChapterCount}");
|
||||
builder.AppendLine($"Scenes: {preview.SceneCount}");
|
||||
builder.AppendLine($"Scene character appearances: {preview.SceneCharacterAppearanceCount}");
|
||||
builder.AppendLine();
|
||||
AppendReportSection(builder, "Errors", preview.Validation.Errors);
|
||||
AppendReportSection(builder, "Warnings", preview.Validation.Warnings);
|
||||
AppendReportSection(builder, "Unmatched POV names", preview.UnmatchedPovNames);
|
||||
AppendReportSection(builder, "Unmatched location names", preview.UnmatchedLocationNames);
|
||||
AppendReportSection(builder, "Unmatched scene character names", preview.UnmatchedSceneCharacterNames);
|
||||
AppendReportSection(builder, "Unresolved thread event references", preview.UnresolvedThreadEventReferences);
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
@ -160,7 +170,11 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
|
||||
Validation = validation,
|
||||
HasDuplicateProjectTitle = duplicate,
|
||||
DuplicateImportProjectTitle = duplicateTitle,
|
||||
BookPreviews = BuildBookPreviews(package)
|
||||
BookPreviews = BuildBookPreviews(package),
|
||||
UnmatchedPovNames = FindUnmatchedPovNames(package),
|
||||
UnmatchedLocationNames = FindUnmatchedLocationNames(package),
|
||||
UnmatchedSceneCharacterNames = FindUnmatchedSceneCharacterNames(package),
|
||||
UnresolvedThreadEventReferences = FindUnresolvedThreadEventReferences(package)
|
||||
};
|
||||
}
|
||||
|
||||
@ -179,10 +193,13 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
|
||||
|
||||
ValidateLargeText(result, "Project description", package.Project.Description);
|
||||
ValidateLargeText(result, "Project notes", package.Project.Notes);
|
||||
ValidateCharacters(package, result);
|
||||
ValidateLocations(package, result);
|
||||
ValidatePlotLines(package, result);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(package.PackageVersion))
|
||||
{
|
||||
result.Warnings.Add("packageVersion is missing. Version 1.0 is expected for Phase 1B.");
|
||||
result.Warnings.Add("packageVersion is missing. Version 1.0 is expected for Phase 1D.");
|
||||
}
|
||||
else if (package.PackageVersion.Trim() != "1.0")
|
||||
{
|
||||
@ -295,13 +312,206 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
|
||||
ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} notes", scene.Notes);
|
||||
ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} purposeText", scene.PurposeText);
|
||||
ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} outcomeText", scene.OutcomeText);
|
||||
|
||||
foreach (var duplicate in scene.SceneCharacters
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.CharacterName))
|
||||
.GroupBy(x => x.CharacterName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Where(x => x.Count() > 1))
|
||||
{
|
||||
result.Errors.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} lists {duplicate.Key} more than once in sceneCharacters.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var name in FindUnmatchedPovNames(package))
|
||||
{
|
||||
result.Warnings.Add($"POV character \"{name}\" does not match a top-level imported character. The text will be preserved in scene notes.");
|
||||
}
|
||||
|
||||
foreach (var name in FindUnmatchedLocationNames(package))
|
||||
{
|
||||
result.Warnings.Add($"Scene location \"{name}\" does not match a top-level imported location. The text will be preserved in scene notes.");
|
||||
}
|
||||
|
||||
foreach (var name in FindUnmatchedSceneCharacterNames(package))
|
||||
{
|
||||
result.Warnings.Add($"Scene character \"{name}\" does not match a top-level imported character and will not create an appearance.");
|
||||
}
|
||||
|
||||
foreach (var reference in FindUnresolvedThreadEventReferences(package))
|
||||
{
|
||||
result.Warnings.Add($"Thread event scene reference could not be resolved and will be skipped: {reference}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ValidatePlotLines(PlotLineImportPackage package, ImportValidationResult result)
|
||||
{
|
||||
foreach (var group in package.PlotLines
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
||||
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Where(x => x.Count() > 1))
|
||||
{
|
||||
result.Errors.Add($"Plot line \"{group.Key}\" appears more than once in the import package.");
|
||||
}
|
||||
|
||||
for (var plotIndex = 0; plotIndex < package.PlotLines.Count; plotIndex++)
|
||||
{
|
||||
var plotLine = package.PlotLines[plotIndex];
|
||||
var plotLabel = string.IsNullOrWhiteSpace(plotLine.Name) ? $"Plot line {plotIndex + 1}" : plotLine.Name.Trim();
|
||||
if (string.IsNullOrWhiteSpace(plotLine.Name))
|
||||
{
|
||||
result.Errors.Add($"Plot line {plotIndex + 1} is missing a name.");
|
||||
}
|
||||
|
||||
if (DisplayName(plotLine.DisplayName, plotLine.Name).Length > TitleMaxLength)
|
||||
{
|
||||
result.Errors.Add($"{plotLabel} name/displayName is longer than {TitleMaxLength} characters.");
|
||||
}
|
||||
|
||||
ValidateLargeText(result, $"{plotLabel} description", plotLine.Description);
|
||||
ValidateLargeText(result, $"{plotLabel} notes", plotLine.Notes);
|
||||
|
||||
foreach (var group in plotLine.Threads
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
||||
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Where(x => x.Count() > 1))
|
||||
{
|
||||
result.Errors.Add($"{plotLabel} has duplicate thread \"{group.Key}\".");
|
||||
}
|
||||
|
||||
for (var threadIndex = 0; threadIndex < plotLine.Threads.Count; threadIndex++)
|
||||
{
|
||||
var thread = plotLine.Threads[threadIndex];
|
||||
var threadLabel = string.IsNullOrWhiteSpace(thread.Name) ? $"Thread {threadIndex + 1}" : thread.Name.Trim();
|
||||
if (string.IsNullOrWhiteSpace(thread.Name))
|
||||
{
|
||||
result.Errors.Add($"{plotLabel} thread {threadIndex + 1} is missing a name.");
|
||||
}
|
||||
|
||||
if (DisplayName(thread.DisplayName, thread.Name).Length > TitleMaxLength)
|
||||
{
|
||||
result.Errors.Add($"{plotLabel} / {threadLabel} name/displayName is longer than {TitleMaxLength} characters.");
|
||||
}
|
||||
|
||||
ValidateLargeText(result, $"{plotLabel} / {threadLabel} description", thread.Description);
|
||||
ValidateLargeText(result, $"{plotLabel} / {threadLabel} notes", thread.Notes);
|
||||
|
||||
foreach (var duplicate in thread.Events
|
||||
.Where(HasSceneOrderReference)
|
||||
.GroupBy(EventSceneKey)
|
||||
.Where(x => x.Count() > 1))
|
||||
{
|
||||
result.Errors.Add($"{plotLabel} / {threadLabel} has duplicate events for scene {duplicate.Key}.");
|
||||
}
|
||||
|
||||
for (var eventIndex = 0; eventIndex < thread.Events.Count; eventIndex++)
|
||||
{
|
||||
var threadEvent = thread.Events[eventIndex];
|
||||
var eventLabel = string.IsNullOrWhiteSpace(threadEvent.Title) ? $"Event {eventIndex + 1}" : threadEvent.Title.Trim();
|
||||
if (!HasSceneOrderReference(threadEvent) && string.IsNullOrWhiteSpace(threadEvent.SceneRef))
|
||||
{
|
||||
result.Warnings.Add($"{plotLabel} / {threadLabel} / {eventLabel} is missing bookOrder/chapterOrder/sceneOrder.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(threadEvent.Title) && string.IsNullOrWhiteSpace(threadEvent.Summary))
|
||||
{
|
||||
result.Errors.Add($"{plotLabel} / {threadLabel} event {eventIndex + 1} needs a title or summary.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(threadEvent.Title) && threadEvent.Title.Trim().Length > TitleMaxLength)
|
||||
{
|
||||
result.Errors.Add($"{plotLabel} / {threadLabel} / {eventLabel} title is longer than {TitleMaxLength} characters.");
|
||||
}
|
||||
|
||||
ValidateLargeText(result, $"{plotLabel} / {threadLabel} / {eventLabel} summary", threadEvent.Summary);
|
||||
ValidateLargeText(result, $"{plotLabel} / {threadLabel} / {eventLabel} impact", threadEvent.Impact);
|
||||
ValidateLargeText(result, $"{plotLabel} / {threadLabel} / {eventLabel} notes", threadEvent.Notes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateCharacters(PlotLineImportPackage package, ImportValidationResult result)
|
||||
{
|
||||
foreach (var group in package.Characters
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
||||
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Where(x => x.Count() > 1))
|
||||
{
|
||||
result.Errors.Add($"Character \"{group.Key}\" appears more than once in the import package.");
|
||||
}
|
||||
|
||||
for (var index = 0; index < package.Characters.Count; index++)
|
||||
{
|
||||
var character = package.Characters[index];
|
||||
var label = string.IsNullOrWhiteSpace(character.Name) ? $"Character {index + 1}" : character.Name.Trim();
|
||||
if (string.IsNullOrWhiteSpace(character.Name))
|
||||
{
|
||||
result.Errors.Add($"Character {index + 1} is missing a name.");
|
||||
}
|
||||
|
||||
if (DisplayName(character.DisplayName, character.Name).Length > TitleMaxLength)
|
||||
{
|
||||
result.Errors.Add($"{label} name/displayName is longer than {TitleMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(character.ShortName) && character.ShortName.Trim().Length > 100)
|
||||
{
|
||||
result.Errors.Add($"{label} shortName is longer than 100 characters.");
|
||||
}
|
||||
|
||||
ValidateLargeText(result, $"{label} description", character.Description);
|
||||
ValidateLargeText(result, $"{label} notes", character.Notes);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateLocations(PlotLineImportPackage package, ImportValidationResult result)
|
||||
{
|
||||
var names = package.Locations
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
||||
.Select(x => x.Name.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var group in package.Locations
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
||||
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Where(x => x.Count() > 1))
|
||||
{
|
||||
result.Errors.Add($"Location \"{group.Key}\" appears more than once in the import package.");
|
||||
}
|
||||
|
||||
for (var index = 0; index < package.Locations.Count; index++)
|
||||
{
|
||||
var location = package.Locations[index];
|
||||
var label = string.IsNullOrWhiteSpace(location.Name) ? $"Location {index + 1}" : location.Name.Trim();
|
||||
if (string.IsNullOrWhiteSpace(location.Name))
|
||||
{
|
||||
result.Errors.Add($"Location {index + 1} is missing a name.");
|
||||
}
|
||||
|
||||
if (DisplayName(location.DisplayName, location.Name).Length > TitleMaxLength)
|
||||
{
|
||||
result.Errors.Add($"{label} name/displayName is longer than {TitleMaxLength} characters.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(location.Type) && location.Type.Trim().Length > 100)
|
||||
{
|
||||
result.Warnings.Add($"{label} location type is longer than 100 characters and may not match an existing type.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(location.ParentLocationName) && !names.Contains(location.ParentLocationName.Trim()))
|
||||
{
|
||||
result.Warnings.Add($"{label} references parentLocationName \"{location.ParentLocationName.Trim()}\", but that location is not in this package. It will be imported without a parent.");
|
||||
}
|
||||
|
||||
ValidateLargeText(result, $"{label} description", location.Description);
|
||||
ValidateLargeText(result, $"{label} notes", location.Notes);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddMetadataWarnings(PlotLineImportPackage package, ImportValidationResult validation)
|
||||
{
|
||||
var hasPov = package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName))));
|
||||
@ -309,12 +519,12 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
|
||||
|
||||
if (hasPov)
|
||||
{
|
||||
validation.Warnings.Add("POV character names will be kept as imported text notes in scene summaries. Character records are not created in Phase 1B.");
|
||||
validation.Warnings.Add("POV character names that match top-level characters will be linked to scenes. Unmatched POV names stay as plain text notes.");
|
||||
}
|
||||
|
||||
if (hasLocation)
|
||||
{
|
||||
validation.Warnings.Add("Location names will be kept as imported text notes in scene summaries. Location records are not created in Phase 1B.");
|
||||
validation.Warnings.Add("Scene location names that match top-level locations will be linked as primary locations. Unmatched location names stay as plain text notes.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -331,6 +541,85 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
|
||||
})
|
||||
.ToList();
|
||||
|
||||
private static IReadOnlyList<string> FindUnmatchedPovNames(PlotLineImportPackage package)
|
||||
{
|
||||
var characterNames = CharacterNames(package);
|
||||
return package.Books
|
||||
.SelectMany(book => book.Chapters)
|
||||
.SelectMany(chapter => chapter.Scenes)
|
||||
.Select(scene => scene.PovCharacterName?.Trim())
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(name => name)
|
||||
.ToList()!;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> FindUnmatchedLocationNames(PlotLineImportPackage package)
|
||||
{
|
||||
var locationNames = LocationNames(package);
|
||||
return package.Books
|
||||
.SelectMany(book => book.Chapters)
|
||||
.SelectMany(chapter => chapter.Scenes)
|
||||
.Select(scene => scene.LocationName?.Trim())
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name) && !locationNames.Contains(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(name => name)
|
||||
.ToList()!;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> FindUnmatchedSceneCharacterNames(PlotLineImportPackage package)
|
||||
{
|
||||
var characterNames = CharacterNames(package);
|
||||
return package.Books
|
||||
.SelectMany(book => book.Chapters)
|
||||
.SelectMany(chapter => chapter.Scenes)
|
||||
.SelectMany(scene => scene.SceneCharacters)
|
||||
.Select(sceneCharacter => sceneCharacter.CharacterName?.Trim())
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name) && !characterNames.Contains(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(name => name)
|
||||
.ToList()!;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> FindUnresolvedThreadEventReferences(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.PlotLines
|
||||
.SelectMany(plotLine => plotLine.Threads.SelectMany(thread => thread.Events.Select((threadEvent, index) => new
|
||||
{
|
||||
PlotLineName = DisplayName(plotLine.DisplayName, plotLine.Name),
|
||||
ThreadName = DisplayName(thread.DisplayName, thread.Name),
|
||||
EventTitle = string.IsNullOrWhiteSpace(threadEvent.Title) ? $"Event {index + 1}" : threadEvent.Title.Trim(),
|
||||
Event = threadEvent
|
||||
})))
|
||||
.Where(x => !HasSceneOrderReference(x.Event) || !sceneKeys.Contains(EventSceneKey(x.Event)))
|
||||
.Select(x => $"{x.PlotLineName} / {x.ThreadName} / {x.EventTitle} -> {EventSceneKey(x.Event)}")
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x)
|
||||
.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 HashSet<string> CharacterNames(PlotLineImportPackage package)
|
||||
=> package.Characters
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
||||
.Select(x => x.Name.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static HashSet<string> LocationNames(PlotLineImportPackage package)
|
||||
=> package.Locations
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
||||
.Select(x => x.Name.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static void ValidateLargeText(ImportValidationResult result, string fieldName, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) && value.Length > LargeTextWarningLength)
|
||||
@ -342,6 +631,9 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
|
||||
private static string FormatBookTitle(ImportBookDto book)
|
||||
=> string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title.Trim() : $"{book.Title.Trim()}: {book.Subtitle.Trim()}";
|
||||
|
||||
private static string DisplayName(string? displayName, string fallback)
|
||||
=> string.IsNullOrWhiteSpace(displayName) ? fallback.Trim() : displayName.Trim();
|
||||
|
||||
private static void AppendReportSection(StringBuilder builder, string title, IReadOnlyList<string> messages)
|
||||
{
|
||||
builder.AppendLine(title);
|
||||
|
||||
@ -21,12 +21,81 @@ public sealed class ImportIndexViewModel
|
||||
public static string SampleJson => """
|
||||
{
|
||||
"packageVersion": "1.0",
|
||||
"source": "JSON Import Phase 1B sample",
|
||||
"source": "JSON Import Phase 1D sample",
|
||||
"project": {
|
||||
"title": "The Glass Meridian",
|
||||
"description": "A placeholder two-book fantasy outline.",
|
||||
"notes": "Imported sample data for testing."
|
||||
},
|
||||
"characters": [
|
||||
{ "name": "Mara", "displayName": "Mara Vale", "shortName": "Mara", "role": "Protagonist", "importance": 10, "description": "Courier drawn into the hidden city.", "notes": "Primary POV for book one." },
|
||||
{ "name": "Ilen", "displayName": "Ilen Or", "shortName": "Ilen", "role": "Guide", "importance": 8, "description": "Watchman with partial knowledge of the old routes.", "notes": "Carries secrets into book two." },
|
||||
{ "name": "Sera", "displayName": "Sera Quill", "shortName": "Sera", "role": "Antagonist", "importance": 7, "description": "Rival mapmaker.", "notes": "Opposes the expedition." },
|
||||
{ "name": "Tovin", "displayName": "Tovin Reed", "shortName": "Tovin", "role": "Supporting", "importance": 5, "description": "Mechanic and signal reader.", "notes": "Useful for technical scenes." }
|
||||
],
|
||||
"locations": [
|
||||
{ "name": "Old Arcade", "displayName": "Old Arcade", "type": "Building", "description": "Covered shopping arcade above the hidden route.", "notes": "Opening location." },
|
||||
{ "name": "Service stair", "displayName": "Service Stair", "type": "Room", "parentLocationName": "Old Arcade", "description": "Narrow stair behind a locked panel.", "notes": "Connects to the lower city." },
|
||||
{ "name": "Lantern Court", "displayName": "Lantern Court", "type": "Landmark", "description": "Underground civic court lit by glass lamps.", "notes": "Central discovery location." },
|
||||
{ "name": "Clockwork Shore", "displayName": "Clockwork Shore", "type": "Outdoor Area", "description": "Mechanical coastline beyond the city.", "notes": "Book two setting." },
|
||||
{ "name": "Tidal causeway", "displayName": "Tidal Causeway", "type": "Outdoor Area", "parentLocationName": "Clockwork Shore", "description": "Crossing exposed at low tide.", "notes": "Final sample scene location." }
|
||||
],
|
||||
"plotLines": [
|
||||
{
|
||||
"name": "Hidden City Mystery",
|
||||
"displayName": "Hidden City Mystery",
|
||||
"type": "Mystery",
|
||||
"importance": 10,
|
||||
"description": "The central question of what lies beneath the city.",
|
||||
"notes": "Sample main mystery line.",
|
||||
"threads": [
|
||||
{
|
||||
"name": "The Brass Key",
|
||||
"displayName": "The Brass Key",
|
||||
"type": "Clue",
|
||||
"status": "Developing",
|
||||
"importance": 8,
|
||||
"description": "The key points toward the lower city.",
|
||||
"notes": "Thread imported from sample JSON.",
|
||||
"events": [
|
||||
{ "bookOrder": 1, "chapterOrder": 1, "sceneOrder": 1, "title": "Key discovered", "eventType": "Introduced", "summary": "Mara finds the brass key.", "impact": "Opens the route beneath the arcade.", "importance": 8, "notes": "First marker." },
|
||||
{ "bookOrder": 1, "chapterOrder": 2, "sceneOrder": 1, "title": "Map gap revealed", "eventType": "Developed", "summary": "The key does not match the visible map.", "impact": "Expands the mystery.", "importance": 7, "notes": "Shows there is more below." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Missing Captain",
|
||||
"displayName": "Missing Captain",
|
||||
"type": "Question",
|
||||
"status": "Introduced",
|
||||
"importance": 6,
|
||||
"description": "A name on the signal plate points to a missing captain.",
|
||||
"events": [
|
||||
{ "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 1, "title": "Captain named", "eventType": "Question Raised", "summary": "The signal names a missing captain.", "impact": "Sets up the next search.", "importance": 6, "notes": "Book two escalation." }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Rival Expedition",
|
||||
"displayName": "Rival Expedition",
|
||||
"type": "Threat",
|
||||
"importance": 7,
|
||||
"description": "Sera's group races the protagonists.",
|
||||
"threads": [
|
||||
{
|
||||
"name": "Sera's Pressure",
|
||||
"displayName": "Sera's Pressure",
|
||||
"type": "Threat",
|
||||
"status": "Developing",
|
||||
"importance": 7,
|
||||
"description": "Sera appears as active opposition.",
|
||||
"events": [
|
||||
{ "bookOrder": 2, "chapterOrder": 2, "sceneOrder": 2, "title": "Camp sighted", "eventType": "Introduced", "summary": "Sera's camp is visible across the causeway.", "impact": "Turns the crossing into a race.", "importance": 7, "notes": "Visible antagonist pressure." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"books": [
|
||||
{
|
||||
"title": "Book One",
|
||||
@ -40,7 +109,7 @@ public sealed class ImportIndexViewModel
|
||||
"chapterNumber": 1,
|
||||
"title": "A Door in the Rain",
|
||||
"order": 1,
|
||||
"summary": "The protagonist finds a hidden route under the old district.",
|
||||
"summary": "Mara finds a hidden route under the old district.",
|
||||
"notes": "Establish the central mystery.",
|
||||
"scenes": [
|
||||
{
|
||||
@ -53,7 +122,11 @@ public sealed class ImportIndexViewModel
|
||||
"dateTimeText": "Rainy evening",
|
||||
"purposeText": "Introduce the mystery",
|
||||
"outcomeText": "Mara keeps the brass key",
|
||||
"notes": "Quiet, tense opening."
|
||||
"notes": "Quiet, tense opening.",
|
||||
"sceneCharacters": [
|
||||
{ "characterName": "Mara", "roleInScene": "POV", "importance": 10, "notes": "Finds the key." },
|
||||
{ "characterName": "Tovin", "roleInScene": "Supporting", "importance": 4, "notes": "Warns Mara not to linger." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"sceneNumber": 2,
|
||||
@ -65,7 +138,11 @@ public sealed class ImportIndexViewModel
|
||||
"dateTimeText": "Same evening",
|
||||
"purposeText": "Escalate the discovery",
|
||||
"outcomeText": "She chooses to descend",
|
||||
"notes": "End on a decision."
|
||||
"notes": "End on a decision.",
|
||||
"sceneCharacters": [
|
||||
{ "characterName": "Mara", "roleInScene": "POV", "importance": 10, "notes": "Chooses to descend." },
|
||||
{ "characterName": "Ilen", "roleInScene": "Present", "importance": 6, "notes": "Unseen at first." }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -73,7 +150,7 @@ public sealed class ImportIndexViewModel
|
||||
"chapterNumber": 2,
|
||||
"title": "The Lantern Court",
|
||||
"order": 2,
|
||||
"summary": "The hidden route opens into a forgotten civic space.",
|
||||
"summary": "The route opens into a forgotten civic space.",
|
||||
"notes": "Add wonder, but keep danger near.",
|
||||
"scenes": [
|
||||
{
|
||||
@ -86,19 +163,27 @@ public sealed class ImportIndexViewModel
|
||||
"dateTimeText": "Later that night",
|
||||
"purposeText": "Reveal the hidden setting",
|
||||
"outcomeText": "Mara realizes the map is incomplete",
|
||||
"notes": "Use strong visual contrast."
|
||||
"notes": "Use strong visual contrast.",
|
||||
"sceneCharacters": [
|
||||
{ "characterName": "Mara", "roleInScene": "POV", "importance": 10, "notes": "First glimpse of the court." },
|
||||
{ "characterName": "Ilen", "roleInScene": "Supporting", "importance": 7, "notes": "Explains only part of the truth." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"sceneNumber": 2,
|
||||
"title": "The Watchman's Offer",
|
||||
"order": 2,
|
||||
"summary": "A stranger offers help at a cost.",
|
||||
"povCharacterName": "Mara",
|
||||
"summary": "Ilen offers help at a cost.",
|
||||
"povCharacterName": "Ilen",
|
||||
"locationName": "Lantern Court",
|
||||
"dateTimeText": "Later that night",
|
||||
"purposeText": "Introduce an uneasy ally",
|
||||
"outcomeText": "Mara accepts a temporary guide",
|
||||
"notes": "Trust should feel provisional."
|
||||
"notes": "Trust should feel provisional.",
|
||||
"sceneCharacters": [
|
||||
{ "characterName": "Ilen", "roleInScene": "POV", "importance": 8, "notes": "Frames the bargain." },
|
||||
{ "characterName": "Mara", "roleInScene": "Present", "importance": 8, "notes": "Accepts with reservations." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -125,11 +210,16 @@ public sealed class ImportIndexViewModel
|
||||
"order": 1,
|
||||
"summary": "The expedition leaves before dawn.",
|
||||
"povCharacterName": "Ilen",
|
||||
"locationName": "East Station",
|
||||
"locationName": "Clockwork Shore",
|
||||
"dateTimeText": "Early morning",
|
||||
"purposeText": "Start the next journey",
|
||||
"outcomeText": "The group leaves the city",
|
||||
"notes": "Keep momentum high."
|
||||
"outcomeText": "The group reaches the shore",
|
||||
"notes": "Keep momentum high.",
|
||||
"sceneCharacters": [
|
||||
{ "characterName": "Ilen", "roleInScene": "POV", "importance": 8, "notes": "Knows the tide table." },
|
||||
{ "characterName": "Mara", "roleInScene": "Present", "importance": 8, "notes": "Questions the plan." },
|
||||
{ "characterName": "Tovin", "roleInScene": "Supporting", "importance": 5, "notes": "Carries tools." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"sceneNumber": 2,
|
||||
@ -141,7 +231,11 @@ public sealed class ImportIndexViewModel
|
||||
"dateTimeText": "Midday",
|
||||
"purposeText": "Show the new world's rules",
|
||||
"outcomeText": "They find a damaged signal plate",
|
||||
"notes": "Make the mechanism readable."
|
||||
"notes": "Make the mechanism readable.",
|
||||
"sceneCharacters": [
|
||||
{ "characterName": "Ilen", "roleInScene": "POV", "importance": 8, "notes": "Recognizes sabotage." },
|
||||
{ "characterName": "Tovin", "roleInScene": "Supporting", "importance": 6, "notes": "Repairs enough to continue." }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -158,23 +252,32 @@ public sealed class ImportIndexViewModel
|
||||
"order": 1,
|
||||
"summary": "The plate reveals a warning.",
|
||||
"povCharacterName": "Mara",
|
||||
"locationName": "Shore workshop",
|
||||
"locationName": "Clockwork Shore",
|
||||
"dateTimeText": "Afternoon",
|
||||
"purposeText": "Translate the clue",
|
||||
"outcomeText": "The warning names a missing captain",
|
||||
"notes": "Make the clue actionable."
|
||||
"notes": "Make the clue actionable.",
|
||||
"sceneCharacters": [
|
||||
{ "characterName": "Mara", "roleInScene": "POV", "importance": 10, "notes": "Interprets the warning." },
|
||||
{ "characterName": "Sera", "roleInScene": "Mentioned", "importance": 6, "notes": "Named by the signal." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"sceneNumber": 2,
|
||||
"title": "Smoke on the Causeway",
|
||||
"order": 2,
|
||||
"summary": "A rival camp appears across the water.",
|
||||
"summary": "Sera's camp appears across the water.",
|
||||
"povCharacterName": "Mara",
|
||||
"locationName": "Tidal causeway",
|
||||
"dateTimeText": "Sunset",
|
||||
"purposeText": "Introduce opposition",
|
||||
"outcomeText": "The group prepares to cross",
|
||||
"notes": "End with forward motion."
|
||||
"notes": "End with forward motion.",
|
||||
"sceneCharacters": [
|
||||
{ "characterName": "Mara", "roleInScene": "POV", "importance": 10, "notes": "Chooses to cross." },
|
||||
{ "characterName": "Sera", "roleInScene": "Antagonist", "importance": 7, "notes": "Visible across the causeway." },
|
||||
{ "characterName": "Ilen", "roleInScene": "Supporting", "importance": 6, "notes": "Warns about the tide." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">Tools</p>
|
||||
<h1>JSON Import</h1>
|
||||
<p class="lead-text">Paste or upload a Phase 1B package, preview it, confirm the summary, then import a new project.</p>
|
||||
<p class="lead-text">Paste or upload a Phase 1D 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>, descriptions, notes, subtitles, chapter summaries, scene summaries, plain-text POV, plain-text location, date/time text, purpose text, and outcome text.</p>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Not imported yet</h3>
|
||||
<p>No AI extraction, Word/text parsing, character records, location records, plot lines, assets, dependencies, relationships, merge/update, or export logic. Unknown JSON fields are ignored safely.</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@ -87,6 +87,12 @@
|
||||
<span><strong>@preview.BookCount</strong> books</span>
|
||||
<span><strong>@preview.ChapterCount</strong> chapters</span>
|
||||
<span><strong>@preview.SceneCount</strong> scenes</span>
|
||||
<span><strong>@preview.CharacterCount</strong> characters</span>
|
||||
<span><strong>@preview.LocationCount</strong> locations</span>
|
||||
<span><strong>@preview.SceneCharacterAppearanceCount</strong> appearances</span>
|
||||
<span><strong>@preview.PlotLineCount</strong> plot lines</span>
|
||||
<span><strong>@preview.PlotThreadCount</strong> threads</span>
|
||||
<span><strong>@preview.ResolvedThreadEventCount</strong> events</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -125,6 +131,28 @@
|
||||
|
||||
@if (preview.Package is not null)
|
||||
{
|
||||
@if (preview.UnmatchedPovNames.Any() || preview.UnmatchedLocationNames.Any() || preview.UnmatchedSceneCharacterNames.Any() || preview.UnresolvedThreadEventReferences.Any())
|
||||
{
|
||||
<div class="import-mapping-summary">
|
||||
@if (preview.UnmatchedPovNames.Any())
|
||||
{
|
||||
<span>Unmatched POV: @string.Join(", ", preview.UnmatchedPovNames)</span>
|
||||
}
|
||||
@if (preview.UnmatchedLocationNames.Any())
|
||||
{
|
||||
<span>Unmatched locations: @string.Join(", ", preview.UnmatchedLocationNames)</span>
|
||||
}
|
||||
@if (preview.UnmatchedSceneCharacterNames.Any())
|
||||
{
|
||||
<span>Unmatched scene characters: @string.Join(", ", preview.UnmatchedSceneCharacterNames)</span>
|
||||
}
|
||||
@if (preview.UnresolvedThreadEventReferences.Any())
|
||||
{
|
||||
<span>Unresolved thread events: @string.Join(", ", preview.UnresolvedThreadEventReferences)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="import-tree">
|
||||
@foreach (var bookPreview in preview.BookPreviews)
|
||||
{
|
||||
@ -145,7 +173,14 @@
|
||||
<ul>
|
||||
@foreach (var scene in chapter.Scenes.OrderBy(x => x.Order))
|
||||
{
|
||||
<li>Scene @scene.SceneNumber: @scene.Title</li>
|
||||
<li>
|
||||
<strong>Scene @scene.SceneNumber: @scene.Title</strong>
|
||||
<span class="import-scene-meta">
|
||||
POV: @(scene.PovCharacterName ?? "Not set") /
|
||||
Location: @(scene.LocationName ?? "Not set") /
|
||||
Characters: @scene.SceneCharacters.Count
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
@ -154,9 +189,33 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (preview.Package.PlotLines.Any())
|
||||
{
|
||||
<div class="import-tree mt-3">
|
||||
@foreach (var plotLine in preview.Package.PlotLines)
|
||||
{
|
||||
<details class="import-tree-book">
|
||||
<summary>
|
||||
<strong>Plot line: @(string.IsNullOrWhiteSpace(plotLine.DisplayName) ? plotLine.Name : plotLine.DisplayName)</strong>
|
||||
<span>@plotLine.Threads.Count threads / @plotLine.Threads.Sum(x => x.Events.Count) events</span>
|
||||
</summary>
|
||||
@foreach (var thread in plotLine.Threads)
|
||||
{
|
||||
<details class="import-tree-chapter">
|
||||
<summary>
|
||||
<strong>@(string.IsNullOrWhiteSpace(thread.DisplayName) ? thread.Name : thread.DisplayName)</strong>
|
||||
<span>@thread.Events.Count events</span>
|
||||
</summary>
|
||||
</details>
|
||||
}
|
||||
</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, and <strong>@preview.SceneCount</strong> scenes.</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, and <strong>@preview.ResolvedThreadEventCount</strong> resolved thread events.</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>
|
||||
|
||||
@ -20,6 +20,12 @@
|
||||
<span><strong>@Model.BooksCreated</strong> books created</span>
|
||||
<span><strong>@Model.ChaptersCreated</strong> chapters created</span>
|
||||
<span><strong>@Model.ScenesCreated</strong> scenes created</span>
|
||||
<span><strong>@Model.CharactersCreated</strong> characters created</span>
|
||||
<span><strong>@Model.LocationsCreated</strong> locations created</span>
|
||||
<span><strong>@Model.SceneCharacterAppearancesCreated</strong> appearances created</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@if (Model.SkippedOptionalFields.Any())
|
||||
@ -35,5 +41,18 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.UnresolvedWarnings.Any())
|
||||
{
|
||||
<div class="alert alert-warning mt-3">
|
||||
<strong>Unresolved mapping warnings</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
@foreach (var warning in Model.UnresolvedWarnings)
|
||||
{
|
||||
<li>@warning</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="muted mt-3 mb-0">The import was transactional, so all listed records were created together.</p>
|
||||
</section>
|
||||
|
||||
@ -262,6 +262,25 @@ td.text-end {
|
||||
color: var(--plotline-muted);
|
||||
}
|
||||
|
||||
.import-scene-meta {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--plotline-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.import-mapping-summary {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(184, 118, 43, 0.28);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
background: rgba(251, 236, 211, 0.5);
|
||||
color: var(--plotline-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.import-duplicate-confirm {
|
||||
border: 1px solid rgba(184, 118, 43, 0.34);
|
||||
border-radius: 8px;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user