743 lines
35 KiB
C#
743 lines
35 KiB
C#
using System.Data;
|
|
using System.Text;
|
|
using Dapper;
|
|
using PlotLine.Models;
|
|
|
|
namespace PlotLine.Data;
|
|
|
|
public interface IImportRepository
|
|
{
|
|
Task<bool> ProjectTitleExistsAsync(string projectTitle);
|
|
Task<ImportResult> ImportAsync(PlotLineImportPackage package, string? importedProjectTitle);
|
|
}
|
|
|
|
public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository
|
|
{
|
|
private const string ImportMarker = "Imported via JSON Import Phase 1D";
|
|
|
|
public async Task<bool> ProjectTitleExistsAsync(string projectTitle)
|
|
{
|
|
using var connection = connectionFactory.CreateConnection();
|
|
var count = await connection.ExecuteScalarAsync<int>(
|
|
"""
|
|
SELECT COUNT(1)
|
|
FROM dbo.Projects
|
|
WHERE IsArchived = 0 AND ProjectName = @ProjectName;
|
|
""",
|
|
new { ProjectName = projectTitle });
|
|
|
|
return count > 0;
|
|
}
|
|
|
|
public async Task<ImportResult> ImportAsync(PlotLineImportPackage package, string? importedProjectTitle)
|
|
{
|
|
using var connection = connectionFactory.CreateConnection();
|
|
connection.Open();
|
|
using var transaction = connection.BeginTransaction();
|
|
|
|
try
|
|
{
|
|
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);
|
|
var lookupIds = await GetLookupIdsAsync(connection, transaction);
|
|
var projectId = await connection.QuerySingleAsync<int>(
|
|
"dbo.Project_Save",
|
|
new
|
|
{
|
|
ProjectID = 0,
|
|
ProjectName = projectName,
|
|
Description = CombineText(
|
|
package.Project.Description,
|
|
package.Project.Notes,
|
|
ImportMetadata(package))
|
|
},
|
|
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>(
|
|
"dbo.Book_Save",
|
|
new
|
|
{
|
|
BookID = 0,
|
|
ProjectID = projectId,
|
|
BookTitle = Clean(FormatBookTitle(book)),
|
|
BookNumber = book.SeriesOrder,
|
|
Description = CombineText(
|
|
book.Description,
|
|
book.DependsOnPreviousBook ? "Depends on previous book." : null,
|
|
book.Notes,
|
|
ImportMarker)
|
|
},
|
|
transaction,
|
|
commandType: CommandType.StoredProcedure);
|
|
booksCreated++;
|
|
|
|
foreach (var chapter in book.Chapters.OrderBy(x => x.Order))
|
|
{
|
|
var chapterId = await connection.QuerySingleAsync<int>(
|
|
"dbo.Chapter_Save",
|
|
new
|
|
{
|
|
ChapterID = 0,
|
|
BookID = bookId,
|
|
chapter.ChapterNumber,
|
|
ChapterTitle = Clean(chapter.Title),
|
|
Summary = CombineText(chapter.Summary, chapter.Notes, ImportMarker),
|
|
RevisionStatusID = lookupIds.RevisionStatusID
|
|
},
|
|
transaction,
|
|
commandType: CommandType.StoredProcedure);
|
|
chaptersCreated++;
|
|
|
|
foreach (var scene in chapter.Scenes.OrderBy(x => x.Order))
|
|
{
|
|
var primaryLocationId = ResolveId(locationMap, scene.LocationName);
|
|
var povCharacterId = ResolveId(characterMap, scene.PovCharacterName);
|
|
var sceneId = await connection.QuerySingleAsync<int>(
|
|
"dbo.Scene_Save",
|
|
new
|
|
{
|
|
SceneID = 0,
|
|
ChapterID = chapterId,
|
|
scene.SceneNumber,
|
|
SceneTitle = Clean(scene.Title),
|
|
Summary = CombineText(
|
|
scene.Summary,
|
|
povCharacterId.HasValue ? null : MetadataLine("POV", scene.PovCharacterName),
|
|
primaryLocationId.HasValue ? null : MetadataLine("Location", scene.LocationName),
|
|
scene.Notes,
|
|
ImportMarker),
|
|
TimeModeID = lookupIds.TimeModeID,
|
|
StartDateTime = (DateTime?)null,
|
|
EndDateTime = (DateTime?)null,
|
|
DurationAmount = (decimal?)null,
|
|
DurationUnitID = (int?)null,
|
|
RelativeTimeText = Truncate(scene.DateTimeText, 200),
|
|
TimeConfidenceID = lookupIds.TimeConfidenceID,
|
|
ScenePurposeNotes = CombineText(scene.PurposeText),
|
|
SceneOutcomeNotes = CombineText(scene.OutcomeText),
|
|
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++;
|
|
}
|
|
}
|
|
}
|
|
|
|
transaction.Commit();
|
|
return new ImportResult
|
|
{
|
|
Succeeded = true,
|
|
ProjectID = projectId,
|
|
ProjectName = projectName,
|
|
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."
|
|
};
|
|
}
|
|
catch
|
|
{
|
|
transaction.Rollback();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static async Task<ImportLookupIds> GetLookupIdsAsync(IDbConnection connection, IDbTransaction transaction)
|
|
{
|
|
var revisionStatusId = await connection.QuerySingleOrDefaultAsync<int?>(
|
|
"SELECT TOP (1) RevisionStatusID FROM dbo.RevisionStatuses WHERE StatusName = N'Planned' ORDER BY SortOrder;",
|
|
transaction: transaction);
|
|
var timeModeId = await connection.QuerySingleOrDefaultAsync<int?>(
|
|
"SELECT TOP (1) TimeModeID FROM dbo.TimeModes WHERE TimeModeName = N'Relative' ORDER BY SortOrder;",
|
|
transaction: transaction);
|
|
var timeConfidenceId = await connection.QuerySingleOrDefaultAsync<int?>(
|
|
"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,
|
|
locationTypes,
|
|
roleTypes,
|
|
presenceTypes,
|
|
plotLineTypes,
|
|
threadTypes,
|
|
threadStatuses,
|
|
threadEventTypes);
|
|
}
|
|
|
|
private static string FormatBookTitle(ImportBookDto book)
|
|
=> string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}";
|
|
|
|
private static string ImportMetadata(PlotLineImportPackage package)
|
|
{
|
|
var source = string.IsNullOrWhiteSpace(package.Source) ? null : $"Source: {package.Source.Trim()}";
|
|
return CombineText(ImportMarker, $"Imported at {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC", source) ?? ImportMarker;
|
|
}
|
|
|
|
private static IReadOnlyList<string> GetSkippedOptionalFields(PlotLineImportPackage package)
|
|
{
|
|
var skipped = new List<string>();
|
|
var characterNames = package.Characters
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.Select(x => x.Name.Trim())
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var locationNames = package.Locations
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.Select(x => x.Name.Trim())
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
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) && !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)
|
|
=> string.IsNullOrWhiteSpace(value) ? null : $"{label}: {value.Trim()}";
|
|
|
|
private static string? Truncate(string? value, int maxLength)
|
|
{
|
|
var clean = Clean(value);
|
|
return clean.Length == 0 ? null : clean[..Math.Min(clean.Length, maxLength)];
|
|
}
|
|
|
|
private static string? CombineText(params string?[] values)
|
|
{
|
|
var parts = values
|
|
.Select(Clean)
|
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
if (parts.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var builder = new StringBuilder();
|
|
foreach (var part in parts)
|
|
{
|
|
if (builder.Length > 0)
|
|
{
|
|
builder.AppendLine().AppendLine();
|
|
}
|
|
|
|
builder.Append(part);
|
|
}
|
|
|
|
return builder.ToString();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|