From f70d610c920c00eaf2241221a1abbbb7334d8575 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 14 Mar 2026 01:10:44 +0100 Subject: [PATCH] Add phase 1 critical import tool --- RolemasterDB.slnx | 1 + sources/critical-import-manifest.json | 12 + .../Data/RolemasterDbInitializer.cs | 4 +- .../Data/RolemasterSeedData.cs | 181 ++--------- src/RolemasterDb.App/rolemaster.db | Bin 135168 -> 159744 bytes .../CriticalImportCommandRunner.cs | 103 +++++++ .../CriticalImportLoader.cs | 133 ++++++++ .../CriticalImportManifest.cs | 6 + .../CriticalImportManifestEntry.cs | 11 + .../CriticalImportManifestLoader.cs | 33 ++ src/RolemasterDb.ImportTool/ExtractOptions.cs | 13 + .../ImportArtifactPaths.cs | 19 ++ .../ImportCommandResult.cs | 9 + src/RolemasterDb.ImportTool/ImportOptions.cs | 13 + src/RolemasterDb.ImportTool/LoadOptions.cs | 13 + .../Parsing/ParsedCriticalColumn.cs | 9 + .../Parsing/ParsedCriticalResult.cs | 15 + .../Parsing/ParsedCriticalRollBand.cs | 9 + .../Parsing/ParsedCriticalTable.cs | 21 ++ .../Parsing/StandardCriticalTableParser.cs | 285 ++++++++++++++++++ .../PdfTextExtractor.cs | 34 +++ src/RolemasterDb.ImportTool/Program.cs | 28 ++ .../RepositoryPaths.cs | 34 +++ src/RolemasterDb.ImportTool/ResetOptions.cs | 13 + .../RolemasterDb.ImportTool.csproj | 18 ++ 25 files changed, 851 insertions(+), 166 deletions(-) create mode 100644 sources/critical-import-manifest.json create mode 100644 src/RolemasterDb.ImportTool/CriticalImportCommandRunner.cs create mode 100644 src/RolemasterDb.ImportTool/CriticalImportLoader.cs create mode 100644 src/RolemasterDb.ImportTool/CriticalImportManifest.cs create mode 100644 src/RolemasterDb.ImportTool/CriticalImportManifestEntry.cs create mode 100644 src/RolemasterDb.ImportTool/CriticalImportManifestLoader.cs create mode 100644 src/RolemasterDb.ImportTool/ExtractOptions.cs create mode 100644 src/RolemasterDb.ImportTool/ImportArtifactPaths.cs create mode 100644 src/RolemasterDb.ImportTool/ImportCommandResult.cs create mode 100644 src/RolemasterDb.ImportTool/ImportOptions.cs create mode 100644 src/RolemasterDb.ImportTool/LoadOptions.cs create mode 100644 src/RolemasterDb.ImportTool/Parsing/ParsedCriticalColumn.cs create mode 100644 src/RolemasterDb.ImportTool/Parsing/ParsedCriticalResult.cs create mode 100644 src/RolemasterDb.ImportTool/Parsing/ParsedCriticalRollBand.cs create mode 100644 src/RolemasterDb.ImportTool/Parsing/ParsedCriticalTable.cs create mode 100644 src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs create mode 100644 src/RolemasterDb.ImportTool/PdfTextExtractor.cs create mode 100644 src/RolemasterDb.ImportTool/Program.cs create mode 100644 src/RolemasterDb.ImportTool/RepositoryPaths.cs create mode 100644 src/RolemasterDb.ImportTool/ResetOptions.cs create mode 100644 src/RolemasterDb.ImportTool/RolemasterDb.ImportTool.csproj diff --git a/RolemasterDB.slnx b/RolemasterDB.slnx index 6b0b200..17ff5d5 100644 --- a/RolemasterDB.slnx +++ b/RolemasterDB.slnx @@ -1,5 +1,6 @@ + diff --git a/sources/critical-import-manifest.json b/sources/critical-import-manifest.json new file mode 100644 index 0000000..696030f --- /dev/null +++ b/sources/critical-import-manifest.json @@ -0,0 +1,12 @@ +{ + "tables": [ + { + "slug": "slash", + "displayName": "Slash Critical Strike Table", + "family": "standard", + "extractionMethod": "text", + "pdfPath": "sources/Slash.pdf", + "enabled": true + } + ] +} diff --git a/src/RolemasterDb.App/Data/RolemasterDbInitializer.cs b/src/RolemasterDb.App/Data/RolemasterDbInitializer.cs index 573fa5c..63eb0d5 100644 --- a/src/RolemasterDb.App/Data/RolemasterDbInitializer.cs +++ b/src/RolemasterDb.App/Data/RolemasterDbInitializer.cs @@ -12,12 +12,12 @@ public static class RolemasterDbInitializer await dbContext.Database.EnsureCreatedAsync(cancellationToken); - if (await dbContext.AttackTables.AnyAsync(cancellationToken) || await dbContext.CriticalTables.AnyAsync(cancellationToken)) + if (await dbContext.AttackTables.AnyAsync(cancellationToken)) { return; } - RolemasterSeedData.Seed(dbContext); + RolemasterSeedData.SeedAttackStarterData(dbContext); await dbContext.SaveChangesAsync(cancellationToken); } } diff --git a/src/RolemasterDb.App/Data/RolemasterSeedData.cs b/src/RolemasterDb.App/Data/RolemasterSeedData.cs index edf677b..487f573 100644 --- a/src/RolemasterDb.App/Data/RolemasterSeedData.cs +++ b/src/RolemasterDb.App/Data/RolemasterSeedData.cs @@ -4,17 +4,28 @@ namespace RolemasterDb.App.Data; public static class RolemasterSeedData { - public static void Seed(RolemasterDbContext dbContext) + public static void SeedAttackStarterData(RolemasterDbContext dbContext) { - var armorTypes = CreateArmorTypes(); - dbContext.ArmorTypes.AddRange(armorTypes); + List armorTypes; + + if (dbContext.ArmorTypes.Any()) + { + armorTypes = dbContext.ArmorTypes.ToList(); + } + else + { + armorTypes = CreateArmorTypes(); + dbContext.ArmorTypes.AddRange(armorTypes); + } + + if (dbContext.AttackTables.Any()) + { + return; + } var armorLookup = armorTypes.ToDictionary(item => item.Code, StringComparer.OrdinalIgnoreCase); var attackTables = CreateAttackTables(armorLookup); dbContext.AttackTables.AddRange(attackTables); - - var criticalTables = CreateCriticalTables(); - dbContext.CriticalTables.AddRange(criticalTables); } private static List CreateArmorTypes() => @@ -131,164 +142,6 @@ public static class RolemasterSeedData } } - private static List CreateCriticalTables() - { - return - [ - CreateCriticalTable( - slug: "slash", - displayName: "Slash Critical", - notes: "Starter subset derived from the critical-table schema design notes.", - matrix: new Dictionary - { - ["A"] = - [ - ("Glancing slash across the forearm.", "+2 hits, 1 bleed"), - ("Rib-level cut knocks the foe back a step.", "+4 hits, 1 stun, 2 bleed"), - ("Deep cut to the thigh unbalances the target.", "+6 hits, 2 stun, 3 bleed"), - ("Shoulder opened; guard collapses.", "+8 hits, 3 stun, 4 bleed"), - ("Neck line opened. The fight is over.", "Instant death") - ], - ["B"] = - [ - ("Slash cuts through the hand and weapon grip wavers.", "+4 hits, 1 stun, 2 bleed"), - ("Diagonal cut across the chest steals breath.", "+6 hits, 2 stun, 3 bleed"), - ("Hip strike drives the foe to one knee.", "+8 hits, 3 stun, 4 bleed"), - ("Sword arm carved deeply; weapon drops.", "+10 hits, 4 stun, 5 bleed"), - ("Throat opened in a full red arc.", "Instant death") - ], - ["C"] = - [ - ("Forearm split to the bone; parry ruined.", "+6 hits, 2 stun, 3 bleed"), - ("Wide cut over the ribs spins the foe.", "+8 hits, 3 stun, 4 bleed"), - ("Hamstring sliced; prone unless magically supported.", "+10 hits, 4 stun, 6 bleed"), - ("Slash rips across face and collar. Vision swims.", "+12 hits, 5 stun, 7 bleed"), - ("Head nearly severed.", "Instant death") - ], - ["D"] = - [ - ("Deep abdominal slice spills momentum and blood together.", "+8 hits, 3 stun, 5 bleed"), - ("Sword tracks from shoulder to sternum. Collapse likely.", "+10 hits, 4 stun, 6 bleed"), - ("Leg cut through muscle; foe falls hard.", "+12 hits, 5 stun, 8 bleed"), - ("Upper arm nearly removed; weapon arm useless.", "+14 hits, 6 stun, 10 bleed"), - ("Killing cut through the neck.", "Instant death") - ], - ["E"] = - [ - ("Massive slash opens chest and the foe staggers blindly.", "+10 hits, 4 stun, 6 bleed"), - ("Spine-grazing cut destroys posture and control.", "+12 hits, 5 stun, 8 bleed"), - ("Leg severed below the knee.", "+14 hits, 6 stun, 10 bleed"), - ("Torso opened from clavicle to hip.", "+16 hits, 8 stun, 12 bleed"), - ("Clean decapitation.", "Instant death") - ] - }), - CreateCriticalTable( - slug: "puncture", - displayName: "Puncture Critical", - notes: "Starter subset seeded for missile and thrust attacks.", - matrix: new Dictionary - { - ["A"] = - [ - ("Point slips into the shoulder.", "+2 hits, 1 bleed"), - ("Stab bites into the upper arm.", "+4 hits, 1 stun, 2 bleed"), - ("Short thrust catches the ribs.", "+5 hits, 2 stun, 2 bleed"), - ("Point pierces the thigh. Movement slows.", "+7 hits, 2 stun, 3 bleed"), - ("Exact thrust to the throat.", "Instant death") - ], - ["B"] = - [ - ("Puncture through the palm forces a drop check.", "+4 hits, 1 stun, 2 bleed"), - ("Arrow buries in the flank.", "+6 hits, 2 stun, 3 bleed"), - ("Deep thrust into the belly doubles the foe over.", "+8 hits, 3 stun, 4 bleed"), - ("Point through the shoulder locks the arm.", "+10 hits, 4 stun, 5 bleed"), - ("Eye socket struck cleanly.", "Instant death") - ], - ["C"] = - [ - ("Stab through the bicep ruins the next parry.", "+6 hits, 2 stun, 3 bleed"), - ("Arrow punches through lung tissue.", "+8 hits, 3 stun, 5 bleed"), - ("Thrust under the ribs steals breath and footing.", "+10 hits, 4 stun, 6 bleed"), - ("Deep puncture pins the foe in place.", "+12 hits, 5 stun, 7 bleed"), - ("Heart strike.", "Instant death") - ], - ["D"] = - [ - ("Weapon point tears through the abdomen.", "+8 hits, 3 stun, 5 bleed"), - ("Arrow lodges near the spine; movement nearly gone.", "+10 hits, 4 stun, 6 bleed"), - ("Lance-like thrust through the torso.", "+12 hits, 5 stun, 8 bleed"), - ("Point enters the neck and exits behind the shoulder.", "+14 hits, 6 stun, 10 bleed"), - ("Brain pierced.", "Instant death") - ], - ["E"] = - [ - ("Massive puncture opens the chest cavity.", "+10 hits, 4 stun, 6 bleed"), - ("Shaft buries to the fletching; foe drops.", "+12 hits, 5 stun, 8 bleed"), - ("Groin-to-spine thrust destroys the body line.", "+14 hits, 6 stun, 10 bleed"), - ("Perfect impalement through the throat and neck.", "+16 hits, 8 stun, 12 bleed"), - ("Instantly fatal puncture.", "Instant death") - ] - }) - ]; - } - - private static CriticalTable CreateCriticalTable( - string slug, - string displayName, - string notes, - IReadOnlyDictionary matrix) - { - var table = new CriticalTable - { - Slug = slug, - DisplayName = displayName, - Family = "standard", - SourceDocument = "Seeded starter data", - Notes = notes - }; - - table.Columns = - [ - new CriticalColumn { ColumnKey = "A", Label = "A", SortOrder = 1 }, - new CriticalColumn { ColumnKey = "B", Label = "B", SortOrder = 2 }, - new CriticalColumn { ColumnKey = "C", Label = "C", SortOrder = 3 }, - new CriticalColumn { ColumnKey = "D", Label = "D", SortOrder = 4 }, - new CriticalColumn { ColumnKey = "E", Label = "E", SortOrder = 5 } - ]; - - table.RollBands = - [ - new CriticalRollBand { Label = "01-20", MinRoll = 1, MaxRoll = 20, SortOrder = 1 }, - new CriticalRollBand { Label = "21-40", MinRoll = 21, MaxRoll = 40, SortOrder = 2 }, - new CriticalRollBand { Label = "41-60", MinRoll = 41, MaxRoll = 60, SortOrder = 3 }, - new CriticalRollBand { Label = "61-80", MinRoll = 61, MaxRoll = 80, SortOrder = 4 }, - new CriticalRollBand { Label = "81-100", MinRoll = 81, MaxRoll = 100, SortOrder = 5 } - ]; - - var columnsByKey = table.Columns.ToDictionary(item => item.ColumnKey, StringComparer.OrdinalIgnoreCase); - var bandsByOrder = table.RollBands.ToDictionary(item => item.SortOrder); - - foreach (var (columnKey, rows) in matrix) - { - for (var index = 0; index < rows.Length; index++) - { - var row = rows[index]; - table.Results.Add(new CriticalResult - { - CriticalColumn = columnsByKey[columnKey], - CriticalRollBand = bandsByOrder[index + 1], - DescriptionText = row.Description, - RawAffixText = row.Affix, - RawCellText = $"{row.Description} {row.Affix}", - ParsedJson = "{}", - ParseStatus = "verified" - }); - } - } - - return table; - } - private static string BuildAttackNotation(int hits, string? severity, string criticalType) { return severity is null diff --git a/src/RolemasterDb.App/rolemaster.db b/src/RolemasterDb.App/rolemaster.db index a03bdaf695318b82bf9c01ed707571cf9947342f..8b072662857ae9ce1a28552f0df9f17741e12e18 100644 GIT binary patch literal 159744 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ϑ?mu30gr&C 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 Cam+XX diff --git a/src/RolemasterDb.ImportTool/CriticalImportCommandRunner.cs b/src/RolemasterDb.ImportTool/CriticalImportCommandRunner.cs new file mode 100644 index 0000000..238775f --- /dev/null +++ b/src/RolemasterDb.ImportTool/CriticalImportCommandRunner.cs @@ -0,0 +1,103 @@ +using RolemasterDb.ImportTool.Parsing; + +namespace RolemasterDb.ImportTool; + +public sealed class CriticalImportCommandRunner +{ + private readonly CriticalImportManifestLoader manifestLoader = new(); + private readonly PdfTextExtractor pdfTextExtractor = new(); + private readonly StandardCriticalTableParser standardParser = new(); + + public async Task RunAsync(ResetOptions options) + { + if (!string.Equals(options.Target, "criticals", StringComparison.OrdinalIgnoreCase)) + { + Console.Error.WriteLine("Only 'criticals' is supported by phase 1."); + return 1; + } + + var loader = new CriticalImportLoader(ResolveDatabasePath(options.DatabasePath)); + var removedTableCount = await loader.ResetCriticalsAsync(); + Console.WriteLine($"Removed {removedTableCount} critical table records."); + return 0; + } + + public async Task RunAsync(ExtractOptions options) + { + var entry = GetManifestEntry(options.Table); + var artifactPaths = CreateArtifactPaths(entry.Slug); + await pdfTextExtractor.ExtractAsync(ResolveRepositoryPath(entry.PdfPath), artifactPaths.ExtractedTextPath); + Console.WriteLine($"Extracted {entry.Slug} to {artifactPaths.ExtractedTextPath}"); + return 0; + } + + public async Task RunAsync(LoadOptions options) + { + var entry = GetManifestEntry(options.Table); + var artifactPaths = CreateArtifactPaths(entry.Slug); + + if (!File.Exists(artifactPaths.ExtractedTextPath)) + { + Console.Error.WriteLine($"Missing extracted text artifact: {artifactPaths.ExtractedTextPath}"); + return 1; + } + + var extractedText = await File.ReadAllTextAsync(artifactPaths.ExtractedTextPath); + var parsedTable = Parse(entry, extractedText); + var loader = new CriticalImportLoader(ResolveDatabasePath(options.DatabasePath)); + var result = await loader.LoadAsync(parsedTable); + + Console.WriteLine( + $"Loaded {result.TableSlug}: {result.ColumnCount} columns, {result.RollBandCount} roll bands, {result.ResultCount} results."); + + return 0; + } + + public async Task RunAsync(ImportOptions options) + { + var extractExitCode = await RunAsync(new ExtractOptions + { + DatabasePath = options.DatabasePath, + Table = options.Table + }); + + if (extractExitCode != 0) + { + return extractExitCode; + } + + return await RunAsync(new LoadOptions + { + DatabasePath = options.DatabasePath, + Table = options.Table + }); + } + + private CriticalImportManifestEntry GetManifestEntry(string tableSlug) + { + var manifest = manifestLoader.Load(RepositoryPaths.Discover().ManifestPath); + return manifest.Tables + .Where(item => item.Enabled) + .SingleOrDefault(item => string.Equals(item.Slug, tableSlug.Trim(), StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"No enabled manifest entry was found for '{tableSlug}'."); + } + + private ParsedCriticalTable Parse(CriticalImportManifestEntry entry, string extractedText) + { + if (!string.Equals(entry.Family, "standard", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Family '{entry.Family}' is not supported by phase 1."); + } + + return standardParser.Parse(entry, extractedText); + } + + private static ImportArtifactPaths CreateArtifactPaths(string slug) => + ImportArtifactPaths.Create(RepositoryPaths.Discover().ArtifactsRootPath, slug); + + private static string ResolveDatabasePath(string? databasePath) => + Path.GetFullPath(databasePath ?? RepositoryPaths.Discover().DefaultDatabasePath); + + private static string ResolveRepositoryPath(string path) => + Path.GetFullPath(Path.Combine(RepositoryPaths.Discover().RootPath, path)); +} diff --git a/src/RolemasterDb.ImportTool/CriticalImportLoader.cs b/src/RolemasterDb.ImportTool/CriticalImportLoader.cs new file mode 100644 index 0000000..080e0f6 --- /dev/null +++ b/src/RolemasterDb.ImportTool/CriticalImportLoader.cs @@ -0,0 +1,133 @@ +using Microsoft.EntityFrameworkCore; + +using RolemasterDb.App.Data; +using RolemasterDb.App.Domain; +using RolemasterDb.ImportTool.Parsing; + +namespace RolemasterDb.ImportTool; + +public sealed class CriticalImportLoader(string databasePath) +{ + public async Task ResetCriticalsAsync(CancellationToken cancellationToken = default) + { + await using var dbContext = CreateDbContext(); + await dbContext.Database.EnsureCreatedAsync(cancellationToken); + + var removedTableCount = await dbContext.CriticalTables.CountAsync(cancellationToken); + await using var transaction = await dbContext.Database.BeginTransactionAsync(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 using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + + await DeleteTableAsync(dbContext, table.Slug, cancellationToken); + + var entity = new CriticalTable + { + Slug = table.Slug, + DisplayName = table.DisplayName, + Family = table.Family, + SourceDocument = table.SourceDocument, + Notes = table.Notes + }; + + entity.Columns = table.Columns + .Select(item => new CriticalColumn + { + ColumnKey = item.ColumnKey, + Label = item.Label, + Role = item.Role, + SortOrder = item.SortOrder + }) + .ToList(); + + entity.RollBands = table.RollBands + .Select(item => new CriticalRollBand + { + Label = item.Label, + MinRoll = item.MinRoll, + MaxRoll = item.MaxRoll, + SortOrder = item.SortOrder + }) + .ToList(); + + var columnsByKey = entity.Columns.ToDictionary(item => item.ColumnKey, StringComparer.OrdinalIgnoreCase); + var rollBandsByLabel = entity.RollBands.ToDictionary(item => item.Label, StringComparer.OrdinalIgnoreCase); + + entity.Results = table.Results + .Select(item => new CriticalResult + { + CriticalColumn = columnsByKey[item.ColumnKey], + CriticalRollBand = rollBandsByLabel[item.RollBandLabel], + RawCellText = item.RawCellText, + DescriptionText = item.DescriptionText, + RawAffixText = item.RawAffixText, + ParsedJson = "{}", + ParseStatus = "raw" + }) + .ToList(); + + dbContext.CriticalTables.Add(entity); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + return new ImportCommandResult(entity.Slug, entity.Columns.Count, entity.RollBands.Count, entity.Results.Count); + } + + private RolemasterDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={databasePath}") + .Options; + + return new RolemasterDbContext(options); + } + + private static async Task DeleteTableAsync( + RolemasterDbContext dbContext, + string slug, + CancellationToken cancellationToken) + { + var tableId = await dbContext.CriticalTables + .Where(item => item.Slug == slug) + .Select(item => (int?)item.Id) + .SingleOrDefaultAsync(cancellationToken); + + if (tableId is null) + { + return; + } + + await dbContext.CriticalResults + .Where(item => item.CriticalTableId == tableId.Value) + .ExecuteDeleteAsync(cancellationToken); + + await dbContext.CriticalGroups + .Where(item => item.CriticalTableId == tableId.Value) + .ExecuteDeleteAsync(cancellationToken); + + await dbContext.CriticalColumns + .Where(item => item.CriticalTableId == tableId.Value) + .ExecuteDeleteAsync(cancellationToken); + + await dbContext.CriticalRollBands + .Where(item => item.CriticalTableId == tableId.Value) + .ExecuteDeleteAsync(cancellationToken); + + await dbContext.CriticalTables + .Where(item => item.Id == tableId.Value) + .ExecuteDeleteAsync(cancellationToken); + } +} diff --git a/src/RolemasterDb.ImportTool/CriticalImportManifest.cs b/src/RolemasterDb.ImportTool/CriticalImportManifest.cs new file mode 100644 index 0000000..cf53b76 --- /dev/null +++ b/src/RolemasterDb.ImportTool/CriticalImportManifest.cs @@ -0,0 +1,6 @@ +namespace RolemasterDb.ImportTool; + +public sealed class CriticalImportManifest +{ + public List Tables { get; set; } = []; +} diff --git a/src/RolemasterDb.ImportTool/CriticalImportManifestEntry.cs b/src/RolemasterDb.ImportTool/CriticalImportManifestEntry.cs new file mode 100644 index 0000000..0b5f3b6 --- /dev/null +++ b/src/RolemasterDb.ImportTool/CriticalImportManifestEntry.cs @@ -0,0 +1,11 @@ +namespace RolemasterDb.ImportTool; + +public sealed class CriticalImportManifestEntry +{ + public string Slug { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string Family { get; set; } = string.Empty; + public string ExtractionMethod { get; set; } = string.Empty; + public string PdfPath { get; set; } = string.Empty; + public bool Enabled { get; set; } = true; +} diff --git a/src/RolemasterDb.ImportTool/CriticalImportManifestLoader.cs b/src/RolemasterDb.ImportTool/CriticalImportManifestLoader.cs new file mode 100644 index 0000000..f093074 --- /dev/null +++ b/src/RolemasterDb.ImportTool/CriticalImportManifestLoader.cs @@ -0,0 +1,33 @@ +using System.Text.Json; + +namespace RolemasterDb.ImportTool; + +public sealed class CriticalImportManifestLoader +{ + public CriticalImportManifest Load(string manifestPath) + { + if (!File.Exists(manifestPath)) + { + throw new FileNotFoundException("The critical import manifest could not be found.", manifestPath); + } + + var manifest = JsonSerializer.Deserialize( + File.ReadAllText(manifestPath), + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (manifest is null || manifest.Tables.Count == 0) + { + throw new InvalidOperationException("The critical import manifest is empty."); + } + + if (manifest.Tables.Any(item => string.IsNullOrWhiteSpace(item.Slug))) + { + throw new InvalidOperationException("Each manifest entry must declare a slug."); + } + + return manifest; + } +} diff --git a/src/RolemasterDb.ImportTool/ExtractOptions.cs b/src/RolemasterDb.ImportTool/ExtractOptions.cs new file mode 100644 index 0000000..481849c --- /dev/null +++ b/src/RolemasterDb.ImportTool/ExtractOptions.cs @@ -0,0 +1,13 @@ +using CommandLine; + +namespace RolemasterDb.ImportTool; + +[Verb("extract", HelpText = "Extract a critical table PDF into a text artifact.")] +public sealed class ExtractOptions +{ + [Value(0, MetaName = "table", Required = true, HelpText = "The manifest slug of the critical table to extract.")] + public string Table { get; set; } = string.Empty; + + [Option('d', "db", HelpText = "Optional SQLite database path. Accepted for command consistency.")] + public string? DatabasePath { get; set; } +} diff --git a/src/RolemasterDb.ImportTool/ImportArtifactPaths.cs b/src/RolemasterDb.ImportTool/ImportArtifactPaths.cs new file mode 100644 index 0000000..7965855 --- /dev/null +++ b/src/RolemasterDb.ImportTool/ImportArtifactPaths.cs @@ -0,0 +1,19 @@ +namespace RolemasterDb.ImportTool; + +public sealed class ImportArtifactPaths +{ + private ImportArtifactPaths(string directoryPath, string extractedTextPath) + { + DirectoryPath = directoryPath; + ExtractedTextPath = extractedTextPath; + } + + public string DirectoryPath { get; } + public string ExtractedTextPath { get; } + + public static ImportArtifactPaths Create(string artifactsRootPath, string tableSlug) + { + var directoryPath = Path.Combine(artifactsRootPath, tableSlug); + return new ImportArtifactPaths(directoryPath, Path.Combine(directoryPath, "extracted.txt")); + } +} diff --git a/src/RolemasterDb.ImportTool/ImportCommandResult.cs b/src/RolemasterDb.ImportTool/ImportCommandResult.cs new file mode 100644 index 0000000..41610a1 --- /dev/null +++ b/src/RolemasterDb.ImportTool/ImportCommandResult.cs @@ -0,0 +1,9 @@ +namespace RolemasterDb.ImportTool; + +public sealed class ImportCommandResult(string tableSlug, int columnCount, int rollBandCount, int resultCount) +{ + public string TableSlug { get; } = tableSlug; + public int ColumnCount { get; } = columnCount; + public int RollBandCount { get; } = rollBandCount; + public int ResultCount { get; } = resultCount; +} diff --git a/src/RolemasterDb.ImportTool/ImportOptions.cs b/src/RolemasterDb.ImportTool/ImportOptions.cs new file mode 100644 index 0000000..2d48043 --- /dev/null +++ b/src/RolemasterDb.ImportTool/ImportOptions.cs @@ -0,0 +1,13 @@ +using CommandLine; + +namespace RolemasterDb.ImportTool; + +[Verb("import", HelpText = "Extract and load a critical table in one step.")] +public sealed class ImportOptions +{ + [Value(0, MetaName = "table", Required = true, HelpText = "The manifest slug of the critical table to import.")] + public string Table { get; set; } = string.Empty; + + [Option('d', "db", HelpText = "Optional SQLite database path.")] + public string? DatabasePath { get; set; } +} diff --git a/src/RolemasterDb.ImportTool/LoadOptions.cs b/src/RolemasterDb.ImportTool/LoadOptions.cs new file mode 100644 index 0000000..64a0f17 --- /dev/null +++ b/src/RolemasterDb.ImportTool/LoadOptions.cs @@ -0,0 +1,13 @@ +using CommandLine; + +namespace RolemasterDb.ImportTool; + +[Verb("load", HelpText = "Load a parsed critical table from its extracted text artifact.")] +public sealed class LoadOptions +{ + [Value(0, MetaName = "table", Required = true, HelpText = "The manifest slug of the critical table to load.")] + public string Table { get; set; } = string.Empty; + + [Option('d', "db", HelpText = "Optional SQLite database path.")] + public string? DatabasePath { get; set; } +} diff --git a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalColumn.cs b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalColumn.cs new file mode 100644 index 0000000..c351c3c --- /dev/null +++ b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalColumn.cs @@ -0,0 +1,9 @@ +namespace RolemasterDb.ImportTool.Parsing; + +public sealed class ParsedCriticalColumn(string columnKey, string label, string role, int sortOrder) +{ + public string ColumnKey { get; } = columnKey; + public string Label { get; } = label; + public string Role { get; } = role; + public int SortOrder { get; } = sortOrder; +} diff --git a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalResult.cs b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalResult.cs new file mode 100644 index 0000000..a8dbb09 --- /dev/null +++ b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalResult.cs @@ -0,0 +1,15 @@ +namespace RolemasterDb.ImportTool.Parsing; + +public sealed class ParsedCriticalResult( + string columnKey, + string rollBandLabel, + string rawCellText, + string descriptionText, + string? rawAffixText) +{ + public string ColumnKey { get; } = columnKey; + public string RollBandLabel { get; } = rollBandLabel; + public string RawCellText { get; } = rawCellText; + public string DescriptionText { get; } = descriptionText; + public string? RawAffixText { get; } = rawAffixText; +} diff --git a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalRollBand.cs b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalRollBand.cs new file mode 100644 index 0000000..db26453 --- /dev/null +++ b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalRollBand.cs @@ -0,0 +1,9 @@ +namespace RolemasterDb.ImportTool.Parsing; + +public sealed class ParsedCriticalRollBand(string label, int minRoll, int? maxRoll, int sortOrder) +{ + public string Label { get; } = label; + public int MinRoll { get; } = minRoll; + public int? MaxRoll { get; } = maxRoll; + public int SortOrder { get; } = sortOrder; +} diff --git a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalTable.cs b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalTable.cs new file mode 100644 index 0000000..cf9d7f2 --- /dev/null +++ b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalTable.cs @@ -0,0 +1,21 @@ +namespace RolemasterDb.ImportTool.Parsing; + +public sealed class ParsedCriticalTable( + string slug, + string displayName, + string family, + string sourceDocument, + string? notes, + IReadOnlyList columns, + IReadOnlyList rollBands, + IReadOnlyList results) +{ + public string Slug { get; } = slug; + public string DisplayName { get; } = displayName; + public string Family { get; } = family; + public string SourceDocument { get; } = sourceDocument; + public string? Notes { get; } = notes; + public IReadOnlyList Columns { get; } = columns; + public IReadOnlyList RollBands { get; } = rollBands; + public IReadOnlyList Results { get; } = results; +} diff --git a/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs b/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs new file mode 100644 index 0000000..7e52e80 --- /dev/null +++ b/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs @@ -0,0 +1,285 @@ +using System.Text.RegularExpressions; + +namespace RolemasterDb.ImportTool.Parsing; + +public sealed class StandardCriticalTableParser +{ + private static readonly Regex ColumnRegex = new(@"\b([A-E])\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex RollBandRegex = new(@"^\s*(?