PlotDirector/PlotLine/Data/ImportRepository.cs
2026-06-01 21:28:09 +01:00

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;
}
}