Files
RolemasterDB/src/RolemasterDb.ImportTool/CriticalImportLoader.cs

511 lines
20 KiB
C#

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<int> 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<ImportCommandResult> 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<string>(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<int> 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<IReadOnlyList<CriticalImageArtifactMetadata>> 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<CriticalImageArtifactMetadata>(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<CriticalSourceImageCrop>(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<RolemasterDbContext>()
.UseSqlite($"Data Source={databasePath}")
.Options;
return new RolemasterDbContext(options);
}
private static Dictionary<string, CriticalGroup> 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<string, CriticalColumn> 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<string, CriticalRollBand> 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<string, CriticalGroup> groupsByKey,
IReadOnlyDictionary<string, CriticalColumn> columnsByKey,
IReadOnlyDictionary<string, CriticalRollBand> 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<ParsedCriticalEffect> effects) =>
effects.Count == 0
? "{}"
: JsonSerializer.Serialize(new { effects }, JsonOptions);
private static string ResolveParseStatus(
IReadOnlyList<ParsedCriticalEffect> effects,
IReadOnlyList<ParsedCriticalBranch> 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();
}