Expose critical curation and source image APIs

This commit is contained in:
2026-03-17 22:28:50 +01:00
parent 57fe9d217d
commit 9d25304a27
11 changed files with 323 additions and 2 deletions

View File

@@ -61,6 +61,9 @@
"rollBand": "66-70",
"groupKey": null,
"columnKey": "C",
"isCurated": false,
"sourcePageNumber": 1,
"sourceImageUrl": "/api/tables/critical/slash/cells/412/source-image",
"rawCellText": "Original imported full cell text",
"descriptionText": "Current curated prose",
"rawAffixText": "+8H - 2S",
@@ -77,6 +80,12 @@
<p class="panel-copy">Use this to retrieve the full editable result graph for one critical-table cell, including nested branches, normalized effects, and review notes for unresolved quick-parse tokens.</p>
</section>
<section class="panel">
<h2 class="panel-title">Cell source image</h2>
<p class="panel-copy"><code>GET /api/tables/critical/{slug}/cells/{resultId}/source-image</code></p>
<p class="panel-copy">Streams the importer-generated PNG crop for the current critical cell. Returns <code>404</code> when the row has no stored crop or the artifact is missing.</p>
</section>
<section class="panel">
<h2 class="panel-title">Cell re-parse</h2>
<p class="panel-copy"><code>POST /api/tables/critical/{slug}/cells/{resultId}/reparse</code></p>
@@ -87,6 +96,7 @@
"rawAffixText": "+8H",
"parseStatus": "partial",
"parsedJson": "{}",
"isCurated": false,
"isDescriptionOverridden": true,
"isRawAffixTextOverridden": false,
"areEffectsOverridden": false,
@@ -103,10 +113,11 @@
<p class="panel-copy"><code>PUT /api/tables/critical/{slug}/cells/{resultId}</code></p>
<pre class="code-block">{
"rawCellText": "Corrected imported text",
"descriptionText": "Rewritten prose after manual review",
"descriptionText": "Rewritten prose after manual review",
"rawAffixText": "+10H - must parry 2 rnds",
"parseStatus": "manually_curated",
"parsedJson": "{\"reviewed\":true}",
"isCurated": true,
"isDescriptionOverridden": true,
"isRawAffixTextOverridden": false,
"areEffectsOverridden": false,

View File

@@ -15,6 +15,9 @@ public sealed class CriticalCellEditorModel
public string ColumnKey { get; set; } = string.Empty;
public string ColumnLabel { get; set; } = string.Empty;
public string ColumnRole { get; set; } = string.Empty;
public bool IsCurated { get; set; }
public int? SourcePageNumber { get; set; }
public string? SourceImageUrl { get; set; }
public string RawCellText { get; set; } = string.Empty;
public string QuickParseInput { get; set; } = string.Empty;
public string DescriptionText { get; set; } = string.Empty;
@@ -43,6 +46,9 @@ public sealed class CriticalCellEditorModel
ColumnKey = response.ColumnKey,
ColumnLabel = response.ColumnLabel,
ColumnRole = response.ColumnRole,
IsCurated = response.IsCurated,
SourcePageNumber = response.SourcePageNumber,
SourceImageUrl = response.SourceImageUrl,
RawCellText = response.RawCellText,
QuickParseInput = response.QuickParseInput,
DescriptionText = response.DescriptionText,
@@ -68,6 +74,7 @@ public sealed class CriticalCellEditorModel
RawAffixText,
ResolveParseStatus(Effects, Branches),
SerializeParsedEffects(Effects),
IsCurated,
IsDescriptionOverridden,
IsRawAffixTextOverridden,
AreEffectsOverridden,
@@ -101,6 +108,9 @@ public sealed class CriticalCellEditorModel
ColumnKey = ColumnKey,
ColumnLabel = ColumnLabel,
ColumnRole = ColumnRole,
IsCurated = IsCurated,
SourcePageNumber = SourcePageNumber,
SourceImageUrl = SourceImageUrl,
RawCellText = RawCellText,
QuickParseInput = QuickParseInput,
DescriptionText = DescriptionText,

View File

@@ -13,6 +13,9 @@ public sealed record CriticalCellEditorResponse(
string ColumnKey,
string ColumnLabel,
string ColumnRole,
bool IsCurated,
int? SourcePageNumber,
string? SourceImageUrl,
string RawCellText,
string QuickParseInput,
string DescriptionText,

View File

@@ -9,6 +9,7 @@ public sealed record CriticalCellUpdateRequest(
string? RawAffixText,
string ParseStatus,
string ParsedJson,
bool IsCurated,
bool IsDescriptionOverridden,
bool IsRawAffixTextOverridden,
bool AreEffectsOverridden,

View File

@@ -0,0 +1,44 @@
namespace RolemasterDb.App.Features;
public sealed class CriticalImportArtifactLocator
{
public CriticalImportArtifactLocator(IHostEnvironment hostEnvironment)
{
ArtifactsRootPath = DiscoverArtifactsRootPath(hostEnvironment.ContentRootPath);
}
public string ArtifactsRootPath { get; }
public string? ResolveStoredPath(string? relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
return null;
}
var candidate = Path.GetFullPath(Path.Combine(
ArtifactsRootPath,
relativePath.Replace('/', Path.DirectorySeparatorChar)));
return candidate.StartsWith(ArtifactsRootPath, StringComparison.OrdinalIgnoreCase)
? candidate
: null;
}
private static string DiscoverArtifactsRootPath(string contentRootPath)
{
var probe = new DirectoryInfo(contentRootPath);
while (probe is not null)
{
if (File.Exists(Path.Combine(probe.FullName, "RolemasterDB.slnx")))
{
return Path.Combine(probe.FullName, "artifacts", "import", "critical");
}
probe = probe.Parent;
}
return Path.Combine(contentRootPath, "artifacts", "import", "critical");
}
}

View File

@@ -110,6 +110,7 @@ public sealed record CriticalTableCellDetail(
string ColumnRole,
string? GroupKey,
string? GroupLabel,
bool IsCurated,
string? Description,
IReadOnlyList<CriticalEffectLookupResponse> Effects,
IReadOnlyList<CriticalBranchLookupResponse> Branches);

View File

@@ -9,7 +9,9 @@ using SharedParsing = RolemasterDb.CriticalParsing;
namespace RolemasterDb.App.Features;
public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbContextFactory)
public sealed class LookupService(
IDbContextFactory<RolemasterDbContext> dbContextFactory,
CriticalImportArtifactLocator? artifactLocator = null)
{
public async Task<LookupReferenceData> GetReferenceDataAsync(CancellationToken cancellationToken = default)
{
@@ -245,6 +247,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
result.CriticalColumn.Role,
result.CriticalGroup?.GroupKey,
result.CriticalGroup?.Label,
result.IsCurated,
result.DescriptionText,
result.Effects
.OrderBy(effect => effect.Id)
@@ -300,6 +303,28 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
return CreateCellEditorResponse(result, currentState, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
}
public async Task<string?> GetCriticalSourceImagePathAsync(string slug, int resultId, CancellationToken cancellationToken = default)
{
if (artifactLocator is null)
{
return null;
}
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var normalizedSlug = NormalizeSlug(slug);
var relativePath = await dbContext.CriticalResults
.AsNoTracking()
.Where(item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug)
.Select(item => item.SourceImagePath)
.SingleOrDefaultAsync(cancellationToken);
var fullPath = artifactLocator.ResolveStoredPath(relativePath);
return fullPath is not null && File.Exists(fullPath)
? fullPath
: null;
}
public async Task<CriticalCellEditorResponse?> ReparseCriticalCellAsync(
string slug,
int resultId,
@@ -363,6 +388,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
result.RawAffixText = NormalizeOptionalText(request.RawAffixText);
result.ParseStatus = request.ParseStatus.Trim();
result.ParsedJson = CriticalCellEditorSnapshot.FromRequest(request).ToJson();
result.IsCurated = request.IsCurated;
ReplaceBaseEffects(dbContext, result, request.Effects);
ReplaceBranches(dbContext, result, request.Branches);
@@ -450,6 +476,9 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
result.CriticalColumn.ColumnKey,
result.CriticalColumn.Label,
result.CriticalColumn.Role,
state.IsCurated,
result.SourcePageNumber,
CreateSourceImageUrl(result),
state.RawCellText,
state.QuickParseInput,
state.DescriptionText,
@@ -596,6 +625,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
result.RawAffixText,
result.ParseStatus,
result.ParsedJson,
result.IsCurated,
snapshot.IsDescriptionOverridden,
snapshot.IsRawAffixTextOverridden,
snapshot.AreEffectsOverridden,
@@ -620,6 +650,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
result.RawAffixText,
result.ParseStatus,
result.ParsedJson,
result.IsCurated,
false,
false,
false,
@@ -645,6 +676,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
RawAffixText: content.RawAffixText,
ParseStatus: ResolveParseStatus(content.Effects, content.Branches),
ParsedJson: SerializeParsedEffects(content.Effects),
IsCurated: false,
IsDescriptionOverridden: false,
IsRawAffixTextOverridden: false,
AreEffectsOverridden: false,
@@ -663,6 +695,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
currentState.IsRawAffixTextOverridden ? currentState.RawAffixText : generatedState.RawAffixText,
generatedState.ParseStatus,
generatedState.ParsedJson,
currentState.IsCurated,
currentState.IsDescriptionOverridden,
currentState.IsRawAffixTextOverridden,
currentState.AreEffectsOverridden,
@@ -992,4 +1025,9 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
private static string NormalizeSlug(string value) =>
value.Trim().Replace(' ', '_').ToLowerInvariant();
private static string? CreateSourceImageUrl(CriticalResult result) =>
string.IsNullOrWhiteSpace(result.SourceImagePath)
? null
: $"/api/tables/critical/{result.CriticalTable.Slug}/cells/{result.Id}/source-image";
}

View File

@@ -9,6 +9,7 @@ var connectionString = builder.Configuration.GetConnectionString("RolemasterDb")
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddDbContextFactory<RolemasterDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddSingleton<CriticalImportArtifactLocator>();
builder.Services.AddScoped<LookupService>();
var app = builder.Build();
@@ -40,6 +41,11 @@ api.MapGet("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, i
var result = await lookupService.GetCriticalCellEditorAsync(slug, resultId, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
});
api.MapGet("/tables/critical/{slug}/cells/{resultId:int}/source-image", async (string slug, int resultId, LookupService lookupService, CancellationToken cancellationToken) =>
{
var filePath = await lookupService.GetCriticalSourceImagePathAsync(slug, resultId, cancellationToken);
return filePath is null ? Results.NotFound() : Results.File(filePath, "image/png");
});
api.MapPost("/tables/critical/{slug}/cells/{resultId:int}/reparse", async (string slug, int resultId, CriticalCellReparseRequest request, LookupService lookupService, CancellationToken cancellationToken) =>
{
var result = await lookupService.ReparseCriticalCellAsync(slug, resultId, request.CurrentState, cancellationToken);