using Microsoft.EntityFrameworkCore; using System.Text.Json; using RolemasterDb.App.Data; using RolemasterDb.App.Domain; using RolemasterDb.ImportTool.Parsing; namespace RolemasterDb.ImportTool; public sealed class CriticalImportLoader(string databasePath) { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; public async Task ResetCriticalsAsync(CancellationToken cancellationToken = default) { await using var dbContext = CreateDbContext(); await dbContext.Database.EnsureCreatedAsync(cancellationToken); await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext, cancellationToken); var removedTableCount = await dbContext.CriticalTables.CountAsync(cancellationToken); await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); await dbContext.CriticalEffects.ExecuteDeleteAsync(cancellationToken); await dbContext.CriticalBranches.ExecuteDeleteAsync(cancellationToken); await dbContext.CriticalResults.ExecuteDeleteAsync(cancellationToken); await dbContext.CriticalGroups.ExecuteDeleteAsync(cancellationToken); await dbContext.CriticalColumns.ExecuteDeleteAsync(cancellationToken); await dbContext.CriticalRollBands.ExecuteDeleteAsync(cancellationToken); await dbContext.CriticalTables.ExecuteDeleteAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return removedTableCount; } public async Task LoadAsync(ParsedCriticalTable table, CancellationToken cancellationToken = default) { await using var dbContext = CreateDbContext(); await dbContext.Database.EnsureCreatedAsync(cancellationToken); await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext, cancellationToken); await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); var entity = await dbContext.CriticalTables .AsSplitQuery() .Include(item => item.Groups) .Include(item => item.Columns) .Include(item => item.RollBands) .Include(item => item.Results) .ThenInclude(result => result.CriticalGroup) .Include(item => item.Results) .ThenInclude(result => result.CriticalColumn) .Include(item => item.Results) .ThenInclude(result => result.CriticalRollBand) .Include(item => item.Results) .ThenInclude(result => result.Effects) .Include(item => item.Results) .ThenInclude(result => result.Branches) .ThenInclude(branch => branch.Effects) .SingleOrDefaultAsync(item => item.Slug == table.Slug, cancellationToken); if (entity is null) { entity = new CriticalTable { Slug = table.Slug }; dbContext.CriticalTables.Add(entity); } entity.DisplayName = table.DisplayName; entity.Family = table.Family; entity.SourceDocument = table.SourceDocument; entity.Notes = table.Notes; var groupsByKey = SynchronizeGroups(entity, table); var columnsByKey = SynchronizeColumns(entity, table); var rollBandsByLabel = SynchronizeRollBands(entity, table); var existingResultsByKey = entity.Results.ToDictionary( item => CreateResultKey(item.CriticalGroup?.GroupKey, item.CriticalColumn.ColumnKey, item.CriticalRollBand.Label), StringComparer.Ordinal); var importedKeys = new HashSet(StringComparer.Ordinal); foreach (var item in table.Results) { var resultKey = CreateResultKey(item.GroupKey, item.ColumnKey, item.RollBandLabel); importedKeys.Add(resultKey); if (!existingResultsByKey.TryGetValue(resultKey, out var existingResult)) { var newResult = CreateResultEntity(item, entity, groupsByKey, columnsByKey, rollBandsByLabel); entity.Results.Add(newResult); continue; } existingResult.CriticalGroup = item.GroupKey is null ? null : groupsByKey[item.GroupKey]; existingResult.CriticalColumn = columnsByKey[item.ColumnKey]; existingResult.CriticalRollBand = rollBandsByLabel[item.RollBandLabel]; ApplyImporterProvenance(existingResult, item); if (existingResult.IsCurated) { continue; } ApplyImportedContent(existingResult, item); ReplaceResultChildren(dbContext, existingResult, item); } foreach (var unmatchedResult in entity.Results .Where(item => !importedKeys.Contains(CreateResultKey(item.CriticalGroup?.GroupKey, item.CriticalColumn.ColumnKey, item.CriticalRollBand.Label))) .ToList()) { if (unmatchedResult.IsCurated) { continue; } RemoveResultGraph(dbContext, unmatchedResult); entity.Results.Remove(unmatchedResult); } RemoveUnusedAxes(dbContext, entity, table); await dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return new ImportCommandResult(entity.Slug, entity.Columns.Count, entity.RollBands.Count, entity.Results.Count); } public async Task RefreshImageArtifactsAsync(ParsedCriticalTable table, CancellationToken cancellationToken = default) { await using var dbContext = CreateDbContext(); await dbContext.Database.EnsureCreatedAsync(cancellationToken); await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext, cancellationToken); await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); var entity = await dbContext.CriticalTables .AsSplitQuery() .Include(item => item.Results) .ThenInclude(result => result.CriticalGroup) .Include(item => item.Results) .ThenInclude(result => result.CriticalColumn) .Include(item => item.Results) .ThenInclude(result => result.CriticalRollBand) .SingleOrDefaultAsync(item => item.Slug == table.Slug, cancellationToken); if (entity is null) { throw new InvalidOperationException( $"Critical table '{table.Slug}' does not exist in the target database."); } var existingResultsByKey = entity.Results.ToDictionary( item => CreateResultKey(item.CriticalGroup?.GroupKey, item.CriticalColumn.ColumnKey, item.CriticalRollBand.Label), StringComparer.Ordinal); var refreshedCount = 0; foreach (var item in table.Results) { var resultKey = CreateResultKey(item.GroupKey, item.ColumnKey, item.RollBandLabel); if (!existingResultsByKey.TryGetValue(resultKey, out var existingResult)) { continue; } ApplyImporterProvenance(existingResult, item); refreshedCount++; } await dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return refreshedCount; } public async Task> LoadImageArtifactMetadataAsync( string tableSlug, CancellationToken cancellationToken = default) { await using var dbContext = CreateDbContext(); await dbContext.Database.EnsureCreatedAsync(cancellationToken); await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext, cancellationToken); var rows = await dbContext.CriticalResults .AsNoTracking() .Where(item => item.CriticalTable.Slug == tableSlug) .OrderBy(item => item.Id) .Select(item => new { item.Id, item.SourcePageNumber, item.SourceImagePath, item.SourceImageCropJson }) .ToListAsync(cancellationToken); if (rows.Count == 0) { throw new InvalidOperationException($"Critical table '{tableSlug}' does not exist in the target database."); } var metadata = new List(rows.Count); foreach (var row in rows) { if (string.IsNullOrWhiteSpace(row.SourceImagePath)) { throw new InvalidOperationException( $"Critical result {row.Id} in table '{tableSlug}' is missing SourceImagePath."); } if (string.IsNullOrWhiteSpace(row.SourceImageCropJson)) { throw new InvalidOperationException( $"Critical result {row.Id} in table '{tableSlug}' is missing SourceImageCropJson."); } var crop = JsonSerializer.Deserialize(row.SourceImageCropJson); if (crop is null) { throw new InvalidOperationException( $"Critical result {row.Id} in table '{tableSlug}' has invalid SourceImageCropJson."); } if (row.SourcePageNumber is not null && row.SourcePageNumber != crop.PageNumber) { throw new InvalidOperationException( $"Critical result {row.Id} in table '{tableSlug}' has mismatched source page metadata."); } metadata.Add(new CriticalImageArtifactMetadata(row.Id, row.SourceImagePath, crop)); } return metadata; } private RolemasterDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseSqlite($"Data Source={databasePath}") .Options; return new RolemasterDbContext(options); } private static Dictionary SynchronizeGroups(CriticalTable entity, ParsedCriticalTable table) { var groupsByKey = entity.Groups.ToDictionary(item => item.GroupKey, StringComparer.OrdinalIgnoreCase); foreach (var parsedGroup in table.Groups) { if (!groupsByKey.TryGetValue(parsedGroup.GroupKey, out var group)) { group = new CriticalGroup { CriticalTable = entity, GroupKey = parsedGroup.GroupKey }; entity.Groups.Add(group); groupsByKey[parsedGroup.GroupKey] = group; } group.Label = parsedGroup.Label; group.SortOrder = parsedGroup.SortOrder; } return groupsByKey; } private static Dictionary SynchronizeColumns(CriticalTable entity, ParsedCriticalTable table) { var columnsByKey = entity.Columns.ToDictionary(item => item.ColumnKey, StringComparer.OrdinalIgnoreCase); foreach (var parsedColumn in table.Columns) { if (!columnsByKey.TryGetValue(parsedColumn.ColumnKey, out var column)) { column = new CriticalColumn { CriticalTable = entity, ColumnKey = parsedColumn.ColumnKey }; entity.Columns.Add(column); columnsByKey[parsedColumn.ColumnKey] = column; } column.Label = parsedColumn.Label; column.Role = parsedColumn.Role; column.SortOrder = parsedColumn.SortOrder; } return columnsByKey; } private static Dictionary SynchronizeRollBands(CriticalTable entity, ParsedCriticalTable table) { var rollBandsByLabel = entity.RollBands.ToDictionary(item => item.Label, StringComparer.OrdinalIgnoreCase); foreach (var parsedRollBand in table.RollBands) { if (!rollBandsByLabel.TryGetValue(parsedRollBand.Label, out var rollBand)) { rollBand = new CriticalRollBand { CriticalTable = entity, Label = parsedRollBand.Label }; entity.RollBands.Add(rollBand); rollBandsByLabel[parsedRollBand.Label] = rollBand; } rollBand.MinRoll = parsedRollBand.MinRoll; rollBand.MaxRoll = parsedRollBand.MaxRoll; rollBand.SortOrder = parsedRollBand.SortOrder; } return rollBandsByLabel; } private static CriticalResult CreateResultEntity( ParsedCriticalResult item, CriticalTable entity, IReadOnlyDictionary groupsByKey, IReadOnlyDictionary columnsByKey, IReadOnlyDictionary rollBandsByLabel) { var result = new CriticalResult { CriticalTable = entity, CriticalGroup = item.GroupKey is null ? null : groupsByKey[item.GroupKey], CriticalColumn = columnsByKey[item.ColumnKey], CriticalRollBand = rollBandsByLabel[item.RollBandLabel], IsCurated = false }; ApplyImportedContent(result, item); ApplyImporterProvenance(result, item); result.Effects = item.Effects .Select(CreateEffectEntity) .ToList(); result.Branches = item.Branches .Select(CreateBranchEntity) .ToList(); return result; } private static void ApplyImportedContent(CriticalResult result, ParsedCriticalResult item) { result.RawCellText = item.RawCellText; result.DescriptionText = item.DescriptionText; result.RawAffixText = item.RawAffixText; result.ParsedJson = SerializeParsedEffects(item.Effects); result.ParseStatus = ResolveParseStatus(item.Effects, item.Branches); } private static void ApplyImporterProvenance(CriticalResult result, ParsedCriticalResult item) { result.SourcePageNumber = item.SourceBounds.PageNumber; result.SourceImagePath = NormalizeOptionalText(item.SourceImagePath); result.SourceImageCropJson = item.SourceImageCrop is null ? null : JsonSerializer.Serialize(item.SourceImageCrop, JsonOptions); } private static void ReplaceResultChildren(RolemasterDbContext dbContext, CriticalResult result, ParsedCriticalResult item) { foreach (var branch in result.Branches) { dbContext.CriticalEffects.RemoveRange(branch.Effects); } dbContext.CriticalEffects.RemoveRange(result.Effects); dbContext.CriticalBranches.RemoveRange(result.Branches); result.Effects.Clear(); result.Branches.Clear(); foreach (var effect in item.Effects) { result.Effects.Add(CreateEffectEntity(effect)); } foreach (var branch in item.Branches) { result.Branches.Add(CreateBranchEntity(branch)); } } private static void RemoveResultGraph(RolemasterDbContext dbContext, CriticalResult result) { foreach (var branch in result.Branches) { dbContext.CriticalEffects.RemoveRange(branch.Effects); } dbContext.CriticalEffects.RemoveRange(result.Effects); dbContext.CriticalBranches.RemoveRange(result.Branches); dbContext.CriticalResults.Remove(result); } private static void RemoveUnusedAxes(RolemasterDbContext dbContext, CriticalTable entity, ParsedCriticalTable table) { var activeGroupKeys = entity.Results .Where(item => item.CriticalGroup is not null) .Select(item => item.CriticalGroup!.GroupKey) .ToHashSet(StringComparer.OrdinalIgnoreCase); var importedGroupKeys = table.Groups .Select(item => item.GroupKey) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var group in entity.Groups .Where(item => !activeGroupKeys.Contains(item.GroupKey) && !importedGroupKeys.Contains(item.GroupKey)) .ToList()) { dbContext.CriticalGroups.Remove(group); entity.Groups.Remove(group); } var activeColumnKeys = entity.Results .Select(item => item.CriticalColumn.ColumnKey) .ToHashSet(StringComparer.OrdinalIgnoreCase); var importedColumnKeys = table.Columns .Select(item => item.ColumnKey) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var column in entity.Columns .Where(item => !activeColumnKeys.Contains(item.ColumnKey) && !importedColumnKeys.Contains(item.ColumnKey)) .ToList()) { dbContext.CriticalColumns.Remove(column); entity.Columns.Remove(column); } var activeRollBandLabels = entity.Results .Select(item => item.CriticalRollBand.Label) .ToHashSet(StringComparer.OrdinalIgnoreCase); var importedRollBandLabels = table.RollBands .Select(item => item.Label) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var rollBand in entity.RollBands .Where(item => !activeRollBandLabels.Contains(item.Label) && !importedRollBandLabels.Contains(item.Label)) .ToList()) { dbContext.CriticalRollBands.Remove(rollBand); entity.RollBands.Remove(rollBand); } } private static CriticalEffect CreateEffectEntity(ParsedCriticalEffect effect) => new() { EffectCode = effect.EffectCode, Target = effect.Target, ValueInteger = effect.ValueInteger, ValueExpression = effect.ValueExpression, DurationRounds = effect.DurationRounds, PerRound = effect.PerRound, Modifier = effect.Modifier, BodyPart = effect.BodyPart, IsPermanent = effect.IsPermanent, SourceType = effect.SourceType, SourceText = effect.SourceText }; private static CriticalBranch CreateBranchEntity(ParsedCriticalBranch branch) => new() { BranchKind = branch.BranchKind, ConditionKey = branch.ConditionKey, ConditionText = branch.ConditionText, ConditionJson = "{}", RawText = branch.RawText, DescriptionText = branch.DescriptionText, RawAffixText = branch.RawAffixText, ParsedJson = SerializeParsedEffects(branch.Effects), SortOrder = branch.SortOrder, Effects = branch.Effects .Select(CreateEffectEntity) .ToList() }; private static string SerializeParsedEffects(IReadOnlyList effects) => effects.Count == 0 ? "{}" : JsonSerializer.Serialize(new { effects }, JsonOptions); private static string ResolveParseStatus( IReadOnlyList effects, IReadOnlyList branches) => effects.Count > 0 || branches.Any(branch => branch.Effects.Count > 0) ? "partial" : "raw"; private static string? NormalizeOptionalText(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static string CreateResultKey(string? groupKey, string columnKey, string rollBandLabel) => $"{NormalizeKey(groupKey)}|{NormalizeKey(columnKey)}|{NormalizeKey(rollBandLabel)}"; private static string NormalizeKey(string? value) => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant(); }