Implement queue-first curation workflow

This commit is contained in:
2026-04-12 00:45:50 +02:00
parent 752593fa62
commit 7843073d13
15 changed files with 1295 additions and 471 deletions

View File

@@ -0,0 +1,99 @@
@using RolemasterDb.App.Frontend.Curation
<div class="critical-editor-card curation-queue-bar">
<div class="curation-queue-bar-header">
<div class="curation-queue-heading">
<h1 class="panel-title">Curation</h1>
<span class="muted">Queue-first repair workspace</span>
</div>
<div class="curation-queue-links">
@if (!string.IsNullOrWhiteSpace(TablesHref))
{
<a class="btn btn-secondary" href="@TablesHref">Open tables</a>
}
@if (!string.IsNullOrWhiteSpace(DiagnosticsHref))
{
<a class="btn btn-secondary" href="@DiagnosticsHref">Open diagnostics</a>
}
</div>
</div>
<SegmentedTabs
Items="ScopeItems"
SelectedValue="SelectedScope"
SelectedValueChanged="SelectedScopeChanged"
AriaLabel="Curation queue scope"
CssClass="curation-scope-tabs"/>
@if (string.Equals(SelectedScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal))
{
<div class="field-shell curation-table-select">
<label for="curation-table-select">Table scope</label>
<select
id="curation-table-select"
class="input-shell"
value="@SelectedTableSlug"
@onchange="HandleTableChanged"
disabled="@IsBusy">
@foreach (var table in Tables)
{
<option value="@table.Key">@table.Label</option>
}
</select>
</div>
}
@if (CurrentItem is not null)
{
<div class="curation-queue-summary">
<StatusChip Tone="warning">Needs curation</StatusChip>
<strong>@CurrentItem.TableName</strong>
<span>Roll band <strong>@CurrentItem.RollBand</strong></span>
<span>Severity <strong>@CurrentItem.ColumnLabel</strong></span>
@if (!string.IsNullOrWhiteSpace(CurrentItem.GroupLabel))
{
<span>Variant <strong>@CurrentItem.GroupLabel</strong></span>
}
<span>Result ID <strong>@CurrentItem.ResultId</strong></span>
</div>
}
</div>
@code {
[Parameter]
public IReadOnlyList<SegmentedTabItem> ScopeItems { get; set; } = [];
[Parameter]
public string SelectedScope { get; set; } = CurationQueueScopes.AllTables;
[Parameter, EditorRequired]
public EventCallback<string> SelectedScopeChanged { get; set; }
[Parameter]
public IReadOnlyList<CriticalTableReference> Tables { get; set; } = [];
[Parameter]
public string SelectedTableSlug { get; set; } = string.Empty;
[Parameter, EditorRequired]
public EventCallback<string> SelectedTableSlugChanged { get; set; }
[Parameter]
public CurationQueueItem? CurrentItem { get; set; }
[Parameter]
public bool IsBusy { get; set; }
[Parameter]
public string? TablesHref { get; set; }
[Parameter]
public string? DiagnosticsHref { get; set; }
private Task HandleTableChanged(ChangeEventArgs args) =>
SelectedTableSlugChanged.InvokeAsync(args.Value?.ToString() ?? string.Empty);
}

View File

@@ -0,0 +1,27 @@
<div class="critical-editor-card curation-empty-state">
<div>
<h2 class="panel-title">@Title</h2>
<p class="muted">@Message</p>
</div>
@if (!string.IsNullOrWhiteSpace(ActionHref) && !string.IsNullOrWhiteSpace(ActionLabel))
{
<a class="btn btn-secondary" href="@ActionHref">@ActionLabel</a>
}
</div>
@code {
[Parameter]
public string Title { get; set; } = "Queue empty";
[Parameter]
public string Message { get; set; } = string.Empty;
[Parameter]
public string? ActionHref { get; set; }
[Parameter]
public string? ActionLabel { get; set; }
}

View File

@@ -0,0 +1,248 @@
@using System.Collections.Generic
@using System.Linq
@using Microsoft.AspNetCore.Components.Web
@if (IsLoading)
{
<div class="curation-workspace-shell">
<p class="muted" role="status">@LoadingMessage</p>
</div>
}
else if (Model is null)
{
<div class="curation-workspace-shell">
@if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage()))
{
<p class="error-text critical-editor-error" role="alert">@GetVisibleErrorMessage()</p>
}
else
{
<p class="muted">@EmptyMessage</p>
}
</div>
}
else
{
<div class="curation-workspace-shell">
@if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage()))
{
<p class="error-text critical-editor-error" role="alert">@GetVisibleErrorMessage()</p>
}
<div class="critical-curation-grid">
<div class="critical-editor-card critical-curation-preview-card @(IsQuickParseMode ? "is-quick-parse" : null)">
@if (IsQuickParseMode)
{
<CriticalCellQuickParseEditor
@ref="quickParseEditor"
Model="Model"
IsDisabled="@(IsSaving || IsReparsing)"
TextAreaCssClass="input-shell critical-editor-textarea critical-curation-quick-parse-textarea"
OnReparse="OnReparse"/>
}
else
{
<button
type="button"
class="critical-curation-preview-button"
@onclick="OnEnterQuickParse"
disabled="@(IsSaving || IsReparsing)">
<CompactCriticalCell
Description="@Model.DescriptionText"
Effects="@CriticalCellPresentation.BuildPreviewEffects(Model)"
Branches="@CriticalCellPresentation.BuildPreviewBranches(Model)"/>
</button>
}
</div>
<div class="critical-editor-card critical-curation-source-card">
@if (!string.IsNullOrWhiteSpace(Model.SourceImageUrl))
{
<img
class="critical-curation-source-image"
src="@Model.SourceImageUrl"
alt="@CriticalCellPresentation.BuildSourceImageAltText(Model)"/>
}
else
{
<div class="critical-curation-source-empty">
<p class="muted">No source image is available for this cell yet.</p>
</div>
}
</div>
</div>
@if (!IsQuickParseMode)
{
@if (GetUsedLegendEntries(Model, LegendEntries) is { Count: > 0 } usedLegendEntries)
{
<div class="critical-curation-legend">
@foreach (var entry in usedLegendEntries)
{
<div class="critical-curation-legend-item" title="@entry.Tooltip">
<span class="legend-symbol">@entry.Symbol</span>
<strong>@entry.Label</strong>
</div>
}
</div>
}
}
<div class="curation-workspace-actions">
@if (IsQuickParseMode)
{
<button type="button" class="btn btn-link" @onclick="OnCancelQuickParse" disabled="@(IsSaving || IsReparsing)">Cancel</button>
<button
type="button"
class="btn-ritual"
@onclick="HandleReparseClickAsync"
@onclick:stopPropagation="true"
@onclick:preventDefault="true"
disabled="@(IsSaving || IsReparsing)">
@(IsReparsing ? ReparseBusyLabel : ReparseActionLabel)
</button>
}
else
{
@if (ShowSecondaryAction)
{
<button type="button" class="@SecondaryActionCssClass" @onclick="OnSecondaryAction" disabled="@(IsSaving || IsReparsing)">
@SecondaryActionLabel
</button>
}
@if (ShowPrimaryAction)
{
<button type="button" class="@PrimaryActionCssClass" @onclick="OnPrimaryAction" disabled="@(IsSaving || IsReparsing)">
@(IsSaving ? PrimaryBusyLabel : PrimaryActionLabel)
</button>
}
}
</div>
</div>
}
@code {
[Parameter, EditorRequired]
public CriticalCellEditorModel? Model { get; set; }
[Parameter]
public bool IsLoading { get; set; }
[Parameter]
public bool IsSaving { get; set; }
[Parameter]
public bool IsReparsing { get; set; }
[Parameter]
public bool IsQuickParseMode { get; set; }
[Parameter]
public string? ErrorMessage { get; set; }
[Parameter]
public string? QuickParseErrorMessage { get; set; }
[Parameter]
public IReadOnlyList<CriticalTableLegendEntry>? LegendEntries { get; set; }
[Parameter]
public string LoadingMessage { get; set; } = "Loading curation preview...";
[Parameter]
public string EmptyMessage { get; set; } = "No curation preview is available.";
[Parameter]
public string PrimaryActionLabel { get; set; } = "Mark curated";
[Parameter]
public string PrimaryBusyLabel { get; set; } = "Saving...";
[Parameter]
public string PrimaryActionCssClass { get; set; } = "btn-ritual";
[Parameter]
public bool ShowPrimaryAction { get; set; } = true;
[Parameter]
public string SecondaryActionLabel { get; set; } = "Open full editor";
[Parameter]
public string SecondaryActionCssClass { get; set; } = "btn btn-secondary";
[Parameter]
public bool ShowSecondaryAction { get; set; } = true;
[Parameter]
public string ReparseActionLabel { get; set; } = "Parse";
[Parameter]
public string ReparseBusyLabel { get; set; } = "Parsing...";
[Parameter, EditorRequired]
public EventCallback OnPrimaryAction { get; set; }
[Parameter, EditorRequired]
public EventCallback OnSecondaryAction { get; set; }
[Parameter, EditorRequired]
public EventCallback OnEnterQuickParse { get; set; }
[Parameter, EditorRequired]
public EventCallback OnCancelQuickParse { get; set; }
[Parameter, EditorRequired]
public EventCallback OnReparse { get; set; }
private CriticalCellQuickParseEditor? quickParseEditor;
private bool shouldFocusQuickParseEditor;
private bool wasQuickParseMode;
protected override void OnParametersSet()
{
if (IsQuickParseMode && !wasQuickParseMode)
{
shouldFocusQuickParseEditor = true;
}
wasQuickParseMode = IsQuickParseMode;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (shouldFocusQuickParseEditor && quickParseEditor is not null)
{
shouldFocusQuickParseEditor = false;
await quickParseEditor.FocusAsync();
}
}
private static IReadOnlyList<CriticalTableLegendEntry> GetUsedLegendEntries(CriticalCellEditorModel model, IReadOnlyList<CriticalTableLegendEntry>? legendEntries)
{
if (legendEntries is null || legendEntries.Count == 0)
{
return [];
}
var usedEffectCodes = model.Effects.Select(effect => effect.EffectCode).Concat(model.Branches.SelectMany(branch => branch.Effects.Select(effect => effect.EffectCode))).Where(effectCode => !string.IsNullOrWhiteSpace(effectCode)).ToHashSet(StringComparer.OrdinalIgnoreCase);
return legendEntries.Where(entry => usedEffectCodes.Contains(entry.EffectCode)).ToList();
}
private async Task HandleReparseClickAsync(MouseEventArgs _)
{
if (quickParseEditor is not null)
{
await quickParseEditor.ReparseAsync();
return;
}
await OnReparse.InvokeAsync();
}
private string? GetVisibleErrorMessage() =>
IsQuickParseMode && !string.IsNullOrWhiteSpace(QuickParseErrorMessage) ? QuickParseErrorMessage : ErrorMessage;
}

View File

@@ -1,8 +1,616 @@
@page "/curation"
@rendermode InteractiveServer
@using RolemasterDb.App.Frontend.AppState
@inject NavigationManager NavigationManager
@inject LookupService LookupService
@inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState
@inject RolemasterDb.App.Frontend.AppState.TableContextState TableContextState
<PageTitle>Curation</PageTitle>
<section class="panel">
<h1 class="panel-title">Curation</h1>
<p class="panel-copy">The dedicated queue-first curation workflow lands in Phase 4. The shell route exists now so navigation can move to the target product model before the workflow rewrite begins.</p>
<section class="panel curation-page">
@if (referenceData is null)
{
<div class="critical-editor-card nested">
<span class="muted" role="status">Loading curation queue...</span>
</div>
}
else if (!hasInitializedContext)
{
<div class="critical-editor-card nested">
<span class="muted" role="status">Restoring curation context...</span>
</div>
}
else
{
<CurationQueueBar
ScopeItems="BuildScopeItems()"
SelectedScope="selectedQueueScope"
SelectedScopeChanged="HandleQueueScopeChangedAsync"
Tables="referenceData.CriticalTables"
SelectedTableSlug="selectedTableSlug"
SelectedTableSlugChanged="HandleSelectedTableChangedAsync"
CurrentItem="currentQueueItem"
IsBusy="IsBusy"
TablesHref="@BuildTablesUri()"
DiagnosticsHref="@BuildDiagnosticsUri()"/>
@if (!string.IsNullOrWhiteSpace(pageError))
{
<p class="error-text critical-editor-error" role="alert">@pageError</p>
}
else if (currentQueueItem is null)
{
<CurationQueueEmptyState
Title="@BuildEmptyStateTitle()"
Message="@BuildEmptyStateMessage()"
ActionHref="@BuildTablesUri()"
ActionLabel="Open tables"/>
}
else
{
<div class="critical-editor-card curation-workspace-frame">
<CurationWorkspace
Model="curationModel"
IsLoading="isCurationLoading"
IsSaving="isCurationSaving"
IsReparsing="isCurationReparsing"
IsQuickParseMode="isCurationQuickParseMode"
ErrorMessage="@curationError"
QuickParseErrorMessage="@curationQuickParseError"
LegendEntries="@(currentTableDetail?.Legend ?? Array.Empty<CriticalTableLegendEntry>())"
PrimaryActionLabel="Mark curated and continue"
SecondaryActionLabel="Open full editor"
OnPrimaryAction="MarkCellCuratedAsync"
OnSecondaryAction="OpenCellEditorAsync"
OnEnterQuickParse="EnterCurationQuickParseMode"
OnCancelQuickParse="CancelCurationQuickParseMode"
OnReparse="ReparseCurationCellAsync"/>
</div>
}
}
</section>
@if (isEditorOpen)
{
<CriticalCellEditorDialog
@key="editorModel"
Model="editorModel"
ComparisonBaseline="editorComparisonBaselineModel"
IsLoading="isEditorLoading"
IsReparsing="isEditorReparsing"
IsSaving="isEditorSaving"
LoadErrorMessage="@editorLoadError"
ReparseErrorMessage="@editorReparseError"
SaveErrorMessage="@editorSaveError"
OnClose="CloseCellEditorAsync"
OnReparse="ReparseCellEditorAsync"
OnSave="SaveCellEditorAsync"/>
}
@code {
private const string ContextDestination = "curation";
private readonly Dictionary<string, CriticalTableDetail?> tableDetailCache = new(StringComparer.OrdinalIgnoreCase);
private LookupReferenceData? referenceData;
private string selectedQueueScope = CurationQueueScopes.AllTables;
private string selectedTableSlug = string.Empty;
private bool hasInitializedContext;
private bool isCurationLoading;
private bool isCurationSaving;
private bool isCurationReparsing;
private bool isCurationQuickParseMode;
private string? pageError;
private string? curationError;
private string? curationQuickParseError;
private CurationQueueItem? currentQueueItem;
private CriticalTableDetail? currentTableDetail;
private CriticalCellEditorModel? curationModel;
private bool isEditorOpen;
private bool isEditorLoading;
private bool isEditorReparsing;
private bool isEditorSaving;
private string? editorLoadError;
private string? editorReparseError;
private string? editorSaveError;
private CriticalCellEditorModel? editorModel;
private CriticalCellEditorModel? editorComparisonBaselineModel;
private bool IsBusy =>
isCurationLoading || isCurationSaving || isCurationReparsing || isEditorLoading || isEditorReparsing || isEditorSaving;
protected override async Task OnInitializedAsync()
{
referenceData = await LookupService.GetReferenceDataAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || hasInitializedContext || referenceData?.CriticalTables.Count is not > 0)
{
return;
}
await PinnedTablesState.InitializeAsync();
var initialContext = await TableContextState.RestoreAsync(NavigationManager.Uri, ContextDestination, referenceData.CriticalTables, TableContextMode.Curation);
selectedQueueScope = CurationQueueScopes.Normalize(initialContext.QueueScope);
selectedTableSlug = initialContext.TableSlug ?? string.Empty;
hasInitializedContext = true;
await LoadQueueAsync(initialContext);
await InvokeAsync(StateHasChanged);
}
private async Task HandleQueueScopeChangedAsync(string scope)
{
selectedQueueScope = CurationQueueScopes.Normalize(scope);
await LoadQueueAsync();
}
private async Task HandleSelectedTableChangedAsync(string tableSlug)
{
selectedTableSlug = tableSlug;
await LoadQueueAsync();
}
private async Task LoadQueueAsync(TableContextSnapshot? preferredContext = null)
{
isCurationLoading = true;
pageError = null;
try
{
var (detail, item) = await ResolveQueueItemAsync(preferredContext);
await ApplyResolvedQueueItemAsync(detail, item, "The selected cell could not be loaded for curation.");
}
catch (Exception exception)
{
currentTableDetail = null;
currentQueueItem = null;
curationModel = null;
curationError = null;
curationQuickParseError = null;
pageError = exception.Message;
}
finally
{
isCurationLoading = false;
}
}
private async Task ApplyResolvedQueueItemAsync(CriticalTableDetail? detail, CurationQueueItem? item, string loadFailureMessage)
{
currentTableDetail = detail;
currentQueueItem = item;
curationModel = null;
curationError = null;
curationQuickParseError = null;
isCurationQuickParseMode = false;
if (item is not null)
{
selectedTableSlug = item.TableSlug;
var response = await LookupService.GetCriticalCellEditorAsync(item.TableSlug, item.ResultId);
if (response is null)
{
curationError = loadFailureMessage;
}
else
{
curationModel = CriticalCellEditorModel.FromResponse(response);
}
}
await PersistAndSyncCurationContextAsync();
}
private async Task<(CriticalTableDetail? Detail, CurationQueueItem? Item)> ResolveQueueItemAsync(TableContextSnapshot? preferredContext = null)
{
var candidateSlugs = GetCandidateTableSlugs();
if (candidateSlugs.Count == 0)
{
return (null, null);
}
foreach (var slug in OrderCandidatesForRestore(candidateSlugs, preferredContext?.TableSlug))
{
var detail = await GetTableDetailAsync(slug);
if (detail is null)
{
continue;
}
if (preferredContext is not null && string.Equals(slug, preferredContext.TableSlug, StringComparison.OrdinalIgnoreCase) && CurationQueueResolver.FindCell(detail, preferredContext) is { IsCurated: false } preferredCell)
{
return (detail, CurationQueueResolver.CreateQueueItem(detail, preferredCell));
}
if (CurationQueueResolver.FindFirstUncurated(detail) is { } firstCell)
{
return (detail, CurationQueueResolver.CreateQueueItem(detail, firstCell));
}
if (string.Equals(selectedQueueScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal))
{
return (detail, null);
}
}
return (null, null);
}
private async Task<(CriticalTableDetail? Detail, CurationQueueItem? Item)> ResolveNextQueueItemAsync()
{
if (currentQueueItem is null)
{
return await ResolveQueueItemAsync();
}
var candidateSlugs = GetCandidateTableSlugs();
var currentTableIndex = candidateSlugs.FindIndex(slug => string.Equals(slug, currentQueueItem.TableSlug, StringComparison.OrdinalIgnoreCase));
if (currentTableIndex < 0)
{
return await ResolveQueueItemAsync();
}
for (var index = currentTableIndex; index < candidateSlugs.Count; index++)
{
var slug = candidateSlugs[index];
var detail = await GetTableDetailAsync(slug, forceRefresh: string.Equals(slug, currentQueueItem.TableSlug, StringComparison.OrdinalIgnoreCase));
if (detail is null)
{
continue;
}
CriticalTableCellDetail? nextCell = string.Equals(slug, currentQueueItem.TableSlug, StringComparison.OrdinalIgnoreCase) ? CurationQueueResolver.FindNextUncurated(detail, currentQueueItem.ResultId) : CurationQueueResolver.FindFirstUncurated(detail);
if (nextCell is not null)
{
return (detail, CurationQueueResolver.CreateQueueItem(detail, nextCell));
}
if (string.Equals(selectedQueueScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal))
{
return (detail, null);
}
}
return (null, null);
}
private async Task<(CriticalTableDetail? Detail, CurationQueueItem? Item)> ResolveCurrentQueueItemAsync()
{
if (currentQueueItem is null)
{
return (null, null);
}
var detail = await GetTableDetailAsync(currentQueueItem.TableSlug, forceRefresh: true);
if (detail is null)
{
return (null, null);
}
var cell = detail.Cells.FirstOrDefault(item => item.ResultId == currentQueueItem.ResultId);
return cell is null ? (detail, null) : (detail, CurationQueueResolver.CreateQueueItem(detail, cell));
}
private async Task<CriticalTableDetail?> GetTableDetailAsync(string slug, bool forceRefresh = false)
{
if (!forceRefresh && tableDetailCache.TryGetValue(slug, out var cachedDetail))
{
return cachedDetail;
}
var detail = await LookupService.GetCriticalTableAsync(slug);
tableDetailCache[slug] = detail;
return detail;
}
private List<string> GetCandidateTableSlugs()
{
if (referenceData?.CriticalTables.Count is not > 0)
{
return [];
}
return selectedQueueScope switch
{
CurationQueueScopes.SelectedTable when !string.IsNullOrWhiteSpace(selectedTableSlug) => [TableContextState.ResolveTableSlug(referenceData.CriticalTables, selectedTableSlug)],
CurationQueueScopes.PinnedSet => referenceData.CriticalTables.Where(table => PinnedTablesState.IsPinned(table.Key)).Select(table => table.Key).ToList(),
_ => referenceData.CriticalTables.Select(table => table.Key).ToList()
};
}
private static IReadOnlyList<string> OrderCandidatesForRestore(IReadOnlyList<string> candidates, string? preferredSlug)
{
if (string.IsNullOrWhiteSpace(preferredSlug))
{
return candidates;
}
var ordered = new List<string>(candidates.Count);
foreach (var slug in candidates)
{
if (string.Equals(slug, preferredSlug, StringComparison.OrdinalIgnoreCase))
{
ordered.Add(slug);
break;
}
}
foreach (var slug in candidates)
{
if (!string.Equals(slug, preferredSlug, StringComparison.OrdinalIgnoreCase))
{
ordered.Add(slug);
}
}
return ordered;
}
private Task EnterCurationQuickParseMode()
{
if (curationModel is null)
{
return Task.CompletedTask;
}
curationQuickParseError = null;
isCurationQuickParseMode = true;
return Task.CompletedTask;
}
private Task CancelCurationQuickParseMode()
{
if (isCurationReparsing)
{
return Task.CompletedTask;
}
curationQuickParseError = null;
isCurationQuickParseMode = false;
return Task.CompletedTask;
}
private async Task ReparseCurationCellAsync()
{
if (curationModel is null || currentQueueItem is null)
{
return;
}
isCurationReparsing = true;
curationQuickParseError = null;
try
{
var response = await LookupService.ReparseCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, curationModel.ToRequest());
if (response is null)
{
curationQuickParseError = "The selected cell could not be re-parsed.";
return;
}
curationModel = CriticalCellEditorModel.FromResponse(response);
isCurationQuickParseMode = false;
await InvokeAsync(StateHasChanged);
}
catch (Exception exception)
{
curationQuickParseError = exception.Message;
}
finally
{
isCurationReparsing = false;
}
}
private async Task MarkCellCuratedAsync()
{
if (curationModel is null || currentQueueItem is null)
{
return;
}
isCurationSaving = true;
curationError = null;
try
{
curationModel.IsCurated = true;
var response = await LookupService.UpdateCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, curationModel.ToRequest());
if (response is null)
{
curationError = "The selected cell could not be marked curated.";
return;
}
await GetTableDetailAsync(currentQueueItem.TableSlug, forceRefresh: true);
var (detail, item) = await ResolveNextQueueItemAsync();
await ApplyResolvedQueueItemAsync(detail, item, "The next cell could not be loaded for curation.");
}
catch (Exception exception)
{
curationError = exception.Message;
}
finally
{
isCurationSaving = false;
}
}
private Task OpenCellEditorAsync()
{
if (curationModel is null)
{
return Task.CompletedTask;
}
editorLoadError = null;
editorReparseError = null;
editorSaveError = null;
editorComparisonBaselineModel = null;
editorModel = curationModel.Clone();
isEditorLoading = false;
isEditorReparsing = false;
isEditorSaving = false;
isEditorOpen = true;
return Task.CompletedTask;
}
private async Task CloseCellEditorAsync()
{
isEditorOpen = false;
isEditorLoading = false;
isEditorReparsing = false;
isEditorSaving = false;
editorLoadError = null;
editorReparseError = null;
editorSaveError = null;
editorModel = null;
editorComparisonBaselineModel = null;
await InvokeAsync(StateHasChanged);
}
private async Task ReparseCellEditorAsync()
{
if (editorModel is null || currentQueueItem is null)
{
return;
}
isEditorReparsing = true;
editorReparseError = null;
try
{
var comparisonBaseline = editorModel.Clone();
var response = await LookupService.ReparseCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, editorModel.ToRequest());
if (response is null)
{
editorReparseError = "The selected cell could not be re-parsed.";
return;
}
editorComparisonBaselineModel = comparisonBaseline;
editorModel = CriticalCellEditorModel.FromResponse(response);
await InvokeAsync(StateHasChanged);
}
catch (Exception exception)
{
editorReparseError = exception.Message;
}
finally
{
isEditorReparsing = false;
}
}
private async Task SaveCellEditorAsync()
{
if (editorModel is null || currentQueueItem is null)
{
return;
}
isEditorSaving = true;
editorSaveError = null;
try
{
var response = await LookupService.UpdateCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, editorModel.ToRequest());
if (response is null)
{
editorSaveError = "The selected cell could not be saved.";
return;
}
await GetTableDetailAsync(currentQueueItem.TableSlug, forceRefresh: true);
await CloseCellEditorAsync();
if (response.IsCurated)
{
var (nextDetail, nextItem) = await ResolveNextQueueItemAsync();
await ApplyResolvedQueueItemAsync(nextDetail, nextItem, "The next cell could not be loaded for curation.");
}
else
{
var (detail, item) = await ResolveCurrentQueueItemAsync();
await ApplyResolvedQueueItemAsync(detail, item, "The edited cell could not be reloaded for curation.");
}
}
catch (Exception exception)
{
editorSaveError = exception.Message;
}
finally
{
isEditorSaving = false;
}
}
private async Task PersistAndSyncCurationContextAsync()
{
var snapshot = BuildCurrentCurationContext();
await TableContextState.PersistAsync(ContextDestination, snapshot);
var targetUri = TableContextState.BuildUri("/curation", snapshot);
if (string.Equals(NavigationManager.ToBaseRelativePath(NavigationManager.Uri), targetUri.TrimStart('/'), StringComparison.Ordinal))
{
return;
}
NavigationManager.NavigateTo(targetUri, replace: true);
}
private TableContextSnapshot BuildCurrentCurationContext() =>
new(TableSlug: string.Equals(selectedQueueScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal) ? selectedTableSlug : currentQueueItem?.TableSlug, GroupKey: currentQueueItem?.GroupKey, ColumnKey: currentQueueItem?.ColumnKey, RollBand: currentQueueItem?.RollBand, ResultId: currentQueueItem?.ResultId, Mode: TableContextMode.Curation, QueueScope: selectedQueueScope);
private string BuildTablesUri()
{
var snapshot = new TableContextSnapshot(TableSlug: currentQueueItem?.TableSlug ?? selectedTableSlug, GroupKey: currentQueueItem?.GroupKey, ColumnKey: currentQueueItem?.ColumnKey, RollBand: currentQueueItem?.RollBand, ResultId: currentQueueItem?.ResultId, Mode: TableContextMode.Reference);
return TableContextState.BuildUri("/tables", snapshot);
}
private string? BuildDiagnosticsUri()
{
if (currentQueueItem is null)
{
return null;
}
var snapshot = new TableContextSnapshot(TableSlug: currentQueueItem.TableSlug, GroupKey: currentQueueItem.GroupKey, ColumnKey: currentQueueItem.ColumnKey, RollBand: currentQueueItem.RollBand, ResultId: currentQueueItem.ResultId, Mode: TableContextMode.Diagnostics);
return TableContextState.BuildUri("/tools/diagnostics", snapshot);
}
private IReadOnlyList<SegmentedTabItem> BuildScopeItems() =>
[
new(CurationQueueScopes.AllTables, "All tables"),
new(CurationQueueScopes.SelectedTable, "Selected table"),
new(CurationQueueScopes.PinnedSet, "Pinned set", PinnedTablesState.Items.Count == 0 ? null : PinnedTablesState.Items.Count.ToString())
];
private string BuildEmptyStateTitle() =>
selectedQueueScope switch
{
CurationQueueScopes.SelectedTable => "This table has no remaining queue items",
CurationQueueScopes.PinnedSet => "Pinned queue is empty",
_ => "Curation queue complete"
};
private string BuildEmptyStateMessage() =>
selectedQueueScope switch
{
CurationQueueScopes.SelectedTable => "Choose another table scope or return to reference mode to inspect already curated entries.",
CurationQueueScopes.PinnedSet when PinnedTablesState.Items.Count == 0 => "Pin one or more tables first, then return to the pinned queue lane.",
CurationQueueScopes.PinnedSet => "Every uncurated result in the pinned set has already been reviewed.",
_ => "No uncurated results remain in the current queue scope."
};
}

View File

@@ -2,6 +2,7 @@
@rendermode InteractiveServer
@using System
@using System.Linq
@using RolemasterDb.App.Frontend.AppState
@inject NavigationManager NavigationManager
@inject LookupService LookupService
@inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState
@@ -87,26 +88,6 @@
}
</section>
@if (isCurationOpen)
{
<CriticalCellCurationDialog
@key="curationModel"
Model="curationModel"
IsLoading="isCurationLoading"
IsSaving="isCurationSaving"
IsReparsing="isCurationReparsing"
IsQuickParseMode="isCurationQuickParseMode"
ErrorMessage="@curationError"
QuickParseErrorMessage="@curationQuickParseError"
LegendEntries="@(tableDetail?.Legend ?? Array.Empty<CriticalTableLegendEntry>())"
OnClose="CloseCellCurationAsync"
OnMarkCurated="MarkCellCuratedAsync"
OnEdit="OpenEditorFromCurationAsync"
OnEnterQuickParse="EnterCurationQuickParseMode"
OnCancelQuickParse="CancelCurationQuickParseMode"
OnReparse="ReparseCurationCellAsync"/>
}
@if (isEditorOpen)
{
<CriticalCellEditorDialog
@@ -141,15 +122,6 @@
private int? editingResultId;
private CriticalCellEditorModel? editorModel;
private CriticalCellEditorModel? editorComparisonBaselineModel;
private bool isCurationOpen;
private bool isCurationLoading;
private bool isCurationSaving;
private bool isCurationReparsing;
private bool isCurationQuickParseMode;
private string? curationError;
private string? curationQuickParseError;
private int? curatingResultId;
private CriticalCellEditorModel? curationModel;
private string referenceMode = TablesReferenceMode.Reference;
private string selectedGroupKey = string.Empty;
private string selectedColumnKey = string.Empty;
@@ -282,228 +254,6 @@
}
}
private async Task OpenCellCurationAsync(int resultId)
{
if (string.IsNullOrWhiteSpace(selectedTableSlug))
{
return;
}
isCurationSaving = false;
isCurationReparsing = false;
isCurationQuickParseMode = false;
isCurationOpen = true;
await LoadCurationCellAsync(resultId, "The selected cell could not be loaded for curation.");
}
private async Task CloseCellCurationAsync()
{
isCurationOpen = false;
isCurationLoading = false;
isCurationSaving = false;
isCurationReparsing = false;
isCurationQuickParseMode = false;
curationError = null;
curationQuickParseError = null;
curatingResultId = null;
curationModel = null;
await InvokeAsync(StateHasChanged);
}
private async Task MarkCellCuratedAsync()
{
if (curationModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || curatingResultId is null)
{
return;
}
isCurationSaving = true;
curationError = null;
try
{
curationModel.IsCurated = true;
var response = await LookupService.UpdateCriticalCellAsync(selectedTableSlug, curatingResultId.Value, curationModel.ToRequest());
if (response is null)
{
curationError = "The selected cell could not be marked curated.";
return;
}
await LoadTableDetailAsync();
var nextResultId = FindNextUncuratedResultId(curatingResultId.Value);
if (nextResultId is null)
{
await CloseCellCurationAsync();
return;
}
await LoadCurationCellAsync(nextResultId.Value, "The next cell could not be loaded for curation.");
}
catch (Exception exception)
{
curationError = exception.Message;
}
finally
{
isCurationSaving = false;
}
}
private async Task OpenEditorFromCurationAsync()
{
if (curatingResultId is null)
{
return;
}
var resultId = curatingResultId.Value;
await CloseCellCurationAsync();
await OpenCellEditorAsync(resultId);
}
private Task EnterCurationQuickParseMode()
{
if (curationModel is null)
{
return Task.CompletedTask;
}
curationQuickParseError = null;
isCurationQuickParseMode = true;
return Task.CompletedTask;
}
private Task CancelCurationQuickParseMode()
{
if (isCurationReparsing)
{
return Task.CompletedTask;
}
curationQuickParseError = null;
isCurationQuickParseMode = false;
return Task.CompletedTask;
}
private async Task LoadCurationCellAsync(int resultId, string loadFailureMessage)
{
curationError = null;
curationQuickParseError = null;
curationModel = null;
curatingResultId = resultId;
isCurationLoading = true;
isCurationQuickParseMode = false;
isCurationReparsing = false;
try
{
var response = await LookupService.GetCriticalCellEditorAsync(selectedTableSlug, resultId);
if (response is null)
{
curationError = loadFailureMessage;
curationModel = null;
return;
}
curationModel = CriticalCellEditorModel.FromResponse(response);
}
catch (Exception exception)
{
curationError = exception.Message;
curationModel = null;
}
finally
{
isCurationLoading = false;
}
}
private async Task ReparseCurationCellAsync()
{
if (curationModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || curatingResultId is null)
{
return;
}
isCurationReparsing = true;
curationQuickParseError = null;
try
{
var response = await ReparseCriticalCellAsync(curationModel, curatingResultId.Value);
if (response is null)
{
curationQuickParseError = "The selected cell could not be re-parsed.";
return;
}
curationModel = CriticalCellEditorModel.FromResponse(response);
isCurationQuickParseMode = false;
await InvokeAsync(StateHasChanged);
}
catch (Exception exception)
{
curationQuickParseError = exception.Message;
}
finally
{
isCurationReparsing = false;
}
}
private int? FindNextUncuratedResultId(int currentResultId)
{
if (tableDetail?.Cells is null || tableDetail.Cells.Count == 0)
{
return null;
}
var orderedCells = tableDetail.Cells.OrderBy(cell => GetGroupSortOrder(cell.GroupKey)).ThenBy(cell => GetColumnSortOrder(cell.ColumnKey)).ThenBy(cell => GetRollBandSortOrder(cell.RollBand)).ToList();
var currentIndex = orderedCells.FindIndex(cell => cell.ResultId == currentResultId);
for (var index = currentIndex + 1; index < orderedCells.Count; index++)
{
if (!orderedCells[index].IsCurated)
{
return orderedCells[index].ResultId;
}
}
return null;
}
private int GetGroupSortOrder(string? groupKey)
{
if (tableDetail is null || string.IsNullOrWhiteSpace(groupKey))
{
return 0;
}
return tableDetail.Groups.FirstOrDefault(group => string.Equals(group.Key, groupKey, StringComparison.OrdinalIgnoreCase))?.SortOrder ?? int.MaxValue;
}
private int GetColumnSortOrder(string columnKey)
{
if (tableDetail is null)
{
return int.MaxValue;
}
return tableDetail.Columns.FirstOrDefault(column => string.Equals(column.Key, columnKey, StringComparison.OrdinalIgnoreCase))?.SortOrder ?? int.MaxValue;
}
private int GetRollBandSortOrder(string rollBandLabel)
{
if (tableDetail is null)
{
return int.MaxValue;
}
return tableDetail.RollBands.FirstOrDefault(rollBand => string.Equals(rollBand.Label, rollBandLabel, StringComparison.OrdinalIgnoreCase))?.SortOrder ?? int.MaxValue;
}
private async Task CloseCellEditorAsync()
{
isEditorOpen = false;
@@ -639,8 +389,18 @@
private Task OpenSelectedCellEditorAsync() =>
selectedCell is null ? Task.CompletedTask : OpenCellEditorAsync(selectedCell.ResultId);
private Task OpenSelectedCellCurationAsync() =>
selectedCell is null ? Task.CompletedTask : OpenCellCurationAsync(selectedCell.ResultId);
private async Task OpenSelectedCellCurationAsync()
{
if (SelectedCellDetail is not { } cellDetail || string.IsNullOrWhiteSpace(selectedTableSlug))
{
return;
}
var snapshot = new TableContextSnapshot(TableSlug: selectedTableSlug, GroupKey: cellDetail.GroupKey, ColumnKey: cellDetail.ColumnKey, RollBand: cellDetail.RollBand, ResultId: cellDetail.ResultId, Mode: TableContextMode.Curation, QueueScope: CurationQueueScopes.SelectedTable);
await TableContextState.PersistAsync("curation", snapshot);
NavigationManager.NavigateTo(TableContextState.BuildUri("/curation", snapshot));
}
private Task ToggleLegend()
{

View File

@@ -1,7 +1,5 @@
@using System.Collections.Generic
@using System.Linq
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using RolemasterDb.App.Components.Curation
@using RolemasterDb.App.Features
@implements IAsyncDisposable
@inject IJSRuntime JSRuntime
@@ -15,8 +13,8 @@
@onpointerup="HandleDialogPointerUp"
@onpointercancel="HandleDialogPointerCancel"
@onpointerdown:stopPropagation="true"
@onpointerup:stopPropagation="true"
@onpointercancel:stopPropagation="true">
@onpointerup:stopPropagation="true"
@onpointercancel:stopPropagation="true">
<header class="critical-editor-header">
<div>
<h3 class="panel-title">Curate Result Card</h3>
@@ -32,120 +30,30 @@
<button type="button" class="btn btn-link critical-editor-close" @onclick="OnClose">Close</button>
</header>
@if (IsLoading)
{
<div class="critical-editor-body">
<p class="muted">Loading curation preview...</p>
</div>
}
else if (Model is null)
{
<div class="critical-editor-body">
@if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage()))
{
<p class="error-text critical-editor-error">@GetVisibleErrorMessage()</p>
}
else
{
<p class="muted">No curation preview is available.</p>
}
</div>
}
else
{
<div class="critical-editor-body critical-curation-body @(IsQuickParseMode ? "is-quick-parse" : null)">
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
<p class="error-text critical-editor-error">@ErrorMessage</p>
}
<div class="critical-curation-grid">
<div class="critical-editor-card critical-curation-preview-card @(IsQuickParseMode ? "is-quick-parse" : null)">
@if (IsQuickParseMode)
{
<CriticalCellQuickParseEditor
@ref="quickParseEditor"
Model="Model"
IsDisabled="@(IsSaving || IsReparsing)"
TextAreaCssClass="input-shell critical-editor-textarea critical-curation-quick-parse-textarea"
OnReparse="OnReparse" />
}
else
{
<button
type="button"
class="critical-curation-preview-button"
@onclick="OnEnterQuickParse"
disabled="@(IsSaving || IsReparsing)">
<CompactCriticalCell
Description="@Model.DescriptionText"
Effects="@CriticalCellPresentation.BuildPreviewEffects(Model)"
Branches="@CriticalCellPresentation.BuildPreviewBranches(Model)" />
</button>
}
</div>
<div class="critical-editor-card critical-curation-source-card">
@if (!string.IsNullOrWhiteSpace(Model.SourceImageUrl))
{
<img
class="critical-curation-source-image"
src="@Model.SourceImageUrl"
alt="@CriticalCellPresentation.BuildSourceImageAltText(Model)" />
}
else
{
<div class="critical-curation-source-empty">
<p class="muted">No source image is available for this cell yet.</p>
</div>
}
</div>
</div>
@if (!IsQuickParseMode)
{
@if (GetUsedLegendEntries(Model, LegendEntries) is { Count: > 0 } usedLegendEntries)
{
<div class="critical-curation-legend">
@foreach (var entry in usedLegendEntries)
{
<div class="critical-curation-legend-item" title="@entry.Tooltip">
<span class="legend-symbol">@entry.Symbol</span>
<strong>@entry.Label</strong>
</div>
}
</div>
}
}
</div>
<footer class="critical-editor-footer">
@if (IsQuickParseMode)
{
<button type="button" class="btn btn-link" @onclick="OnCancelQuickParse" disabled="@(IsSaving || IsReparsing)">Cancel</button>
<button
type="button"
class="btn-ritual"
@onclick="HandleReparseClickAsync"
@onclick:stopPropagation="true"
@onclick:preventDefault="true"
disabled="@(IsSaving || IsReparsing)">
@(IsReparsing ? "Parsing..." : "Parse")
</button>
}
else
{
<button type="button" class="btn btn-link" @onclick="OnEdit" disabled="@(IsSaving || IsReparsing)">Edit</button>
<button type="button" class="btn-ritual" @onclick="OnMarkCurated" disabled="@(IsSaving || IsReparsing)">
@(IsSaving ? "Saving..." : "Mark as Curated")
</button>
}
</footer>
}
<div class="critical-editor-body critical-curation-body @(IsQuickParseMode ? "is-quick-parse" : null)">
<CurationWorkspace
Model="Model"
IsLoading="IsLoading"
IsSaving="IsSaving"
IsReparsing="IsReparsing"
IsQuickParseMode="IsQuickParseMode"
ErrorMessage="@ErrorMessage"
QuickParseErrorMessage="@QuickParseErrorMessage"
LegendEntries="LegendEntries"
PrimaryActionLabel="Mark as Curated"
SecondaryActionLabel="Edit"
SecondaryActionCssClass="btn btn-link"
OnPrimaryAction="OnMarkCurated"
OnSecondaryAction="OnEdit"
OnEnterQuickParse="OnEnterQuickParse"
OnCancelQuickParse="OnCancelQuickParse"
OnReparse="OnReparse"/>
</div>
</div>
</div>
@code {
[Parameter, EditorRequired]
public CriticalCellEditorModel? Model { get; set; }
@@ -190,25 +98,15 @@
private IJSObjectReference? jsModule;
private bool isBackdropPointerDown;
private CriticalCellQuickParseEditor? quickParseEditor;
private bool shouldFocusQuickParseEditor;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
"import",
"./Components/Shared/CriticalCellEditorDialog.razor.js");
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./Components/Shared/CriticalCellEditorDialog.razor.js");
await jsModule.InvokeVoidAsync("lockBackgroundScroll");
}
if (shouldFocusQuickParseEditor && quickParseEditor is not null)
{
shouldFocusQuickParseEditor = false;
await quickParseEditor.FocusAsync();
}
}
public async ValueTask DisposeAsync()
@@ -265,51 +163,6 @@
}
private static string BuildColumnDisplayText(CriticalCellEditorModel model) =>
string.IsNullOrWhiteSpace(model.GroupLabel)
? model.ColumnLabel
: $"{model.GroupLabel} / {model.ColumnLabel}";
string.IsNullOrWhiteSpace(model.GroupLabel) ? model.ColumnLabel : $"{model.GroupLabel} / {model.ColumnLabel}";
private static IReadOnlyList<CriticalTableLegendEntry> GetUsedLegendEntries(
CriticalCellEditorModel model,
IReadOnlyList<CriticalTableLegendEntry>? legendEntries)
{
if (legendEntries is null || legendEntries.Count == 0)
{
return [];
}
var usedEffectCodes = model.Effects
.Select(effect => effect.EffectCode)
.Concat(model.Branches.SelectMany(branch => branch.Effects.Select(effect => effect.EffectCode)))
.Where(effectCode => !string.IsNullOrWhiteSpace(effectCode))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return legendEntries
.Where(entry => usedEffectCodes.Contains(entry.EffectCode))
.ToList();
}
private async Task HandleReparseClickAsync(MouseEventArgs _)
{
if (quickParseEditor is not null)
{
await quickParseEditor.ReparseAsync();
return;
}
await OnReparse.InvokeAsync();
}
private string? GetVisibleErrorMessage() =>
IsQuickParseMode && !string.IsNullOrWhiteSpace(QuickParseErrorMessage)
? QuickParseErrorMessage
: ErrorMessage;
protected override void OnParametersSet()
{
if (IsQuickParseMode && quickParseEditor is null)
{
shouldFocusQuickParseEditor = true;
}
}
}
}

View File

@@ -10,9 +10,11 @@
@using RolemasterDb.App.Features
@using RolemasterDb.App
@using RolemasterDb.App.Components
@using RolemasterDb.App.Components.Curation
@using RolemasterDb.App.Components.Layout
@using RolemasterDb.App.Components.Primitives
@using RolemasterDb.App.Components.Shell
@using RolemasterDb.App.Components.Shared
@using RolemasterDb.App.Components.Tables
@using RolemasterDb.App.Components.Tools
@using RolemasterDb.App.Frontend.Curation

View File

@@ -1,10 +1,3 @@
namespace RolemasterDb.App.Frontend.AppState;
public sealed record TableContextSnapshot(
string? TableSlug = null,
string? GroupKey = null,
string? ColumnKey = null,
string? RollBand = null,
int? RollJump = null,
int? ResultId = null,
TableContextMode? Mode = null);
public sealed record TableContextSnapshot(string? TableSlug = null, string? GroupKey = null, string? ColumnKey = null, string? RollBand = null, int? RollJump = null, int? ResultId = null, TableContextMode? Mode = null, string? QueueScope = null);

View File

@@ -11,20 +11,14 @@ public sealed class TableContextUrlSerializer
private const string RollJumpKey = "roll";
private const string ResultIdKey = "result";
private const string ModeKey = "mode";
private const string QueueScopeKey = "scope";
public TableContextSnapshot Parse(string uri)
{
var currentUri = new Uri(uri, UriKind.Absolute);
var query = QueryHelpers.ParseQuery(currentUri.Query);
return new TableContextSnapshot(
ReadValue(query, TableKey),
ReadValue(query, GroupKey),
ReadValue(query, ColumnKey),
ReadValue(query, RollBandKey),
ReadInt(query, RollJumpKey),
ReadInt(query, ResultIdKey),
ReadMode(ReadValue(query, ModeKey)));
return new TableContextSnapshot(ReadValue(query, TableKey), ReadValue(query, GroupKey), ReadValue(query, ColumnKey), ReadValue(query, RollBandKey), ReadInt(query, RollJumpKey), ReadInt(query, ResultIdKey), ReadMode(ReadValue(query, ModeKey)), ReadValue(query, QueueScopeKey));
}
public string BuildRelativeUri(string basePath, TableContextSnapshot context)
@@ -37,10 +31,9 @@ public sealed class TableContextUrlSerializer
AddIfPresent(parameters, RollJumpKey, context.RollJump?.ToString());
AddIfPresent(parameters, ResultIdKey, context.ResultId?.ToString());
AddIfPresent(parameters, ModeKey, WriteMode(context.Mode));
AddIfPresent(parameters, QueueScopeKey, context.QueueScope);
return parameters.Count == 0
? basePath
: QueryHelpers.AddQueryString(basePath, parameters);
return parameters.Count == 0 ? basePath : QueryHelpers.AddQueryString(basePath, parameters);
}
private static void AddIfPresent(IDictionary<string, string?> parameters, string key, string? value)
@@ -52,9 +45,7 @@ public sealed class TableContextUrlSerializer
}
private static string? ReadValue(IReadOnlyDictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key) =>
query.TryGetValue(key, out var value)
? value.ToString()
: null;
query.TryGetValue(key, out var value) ? value.ToString() : null;
private static int? ReadInt(IReadOnlyDictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key)
{
@@ -65,18 +56,18 @@ public sealed class TableContextUrlSerializer
private static TableContextMode? ReadMode(string? value) =>
value?.Trim().ToLowerInvariant() switch
{
"reference" => TableContextMode.Reference,
"curation" => TableContextMode.Curation,
"reference" => TableContextMode.Reference,
"curation" => TableContextMode.Curation,
"diagnostics" => TableContextMode.Diagnostics,
_ => null
_ => null
};
private static string? WriteMode(TableContextMode? mode) =>
mode switch
{
TableContextMode.Reference => "reference",
TableContextMode.Curation => "curation",
TableContextMode.Reference => "reference",
TableContextMode.Curation => "curation",
TableContextMode.Diagnostics => "diagnostics",
_ => null
_ => null
};
}
}

View File

@@ -0,0 +1,3 @@
namespace RolemasterDb.App.Frontend.Curation;
public sealed record CurationQueueItem(string TableSlug, string TableName, int ResultId, string RollBand, string ColumnKey, string ColumnLabel, string? GroupKey, string? GroupLabel);

View File

@@ -0,0 +1,95 @@
using RolemasterDb.App.Features;
using RolemasterDb.App.Frontend.AppState;
namespace RolemasterDb.App.Frontend.Curation;
public static class CurationQueueResolver
{
public static IReadOnlyList<CriticalTableCellDetail> GetOrderedCells(CriticalTableDetail detail)
{
ArgumentNullException.ThrowIfNull(detail);
var rollOrder = detail.RollBands.Select((rollBand, index) => new
{
rollBand.Label,
index
}).ToDictionary(item => item.Label, item => item.index, StringComparer.Ordinal);
var columnOrder = detail.Columns.Select((column, index) => new
{
column.Key,
index
}).ToDictionary(item => item.Key, item => item.index, StringComparer.Ordinal);
var groupOrder = detail.Groups.Select((group, index) => new
{
group.Key,
index
}).ToDictionary(item => item.Key, item => item.index, StringComparer.Ordinal);
return detail.Cells.OrderBy(cell => rollOrder.GetValueOrDefault(cell.RollBand, int.MaxValue)).ThenBy(cell =>
{
if (string.IsNullOrWhiteSpace(cell.GroupKey))
{
return -1;
}
return groupOrder.GetValueOrDefault(cell.GroupKey, int.MaxValue);
}).ThenBy(cell => columnOrder.GetValueOrDefault(cell.ColumnKey, int.MaxValue)).ToList();
}
public static CriticalTableCellDetail? FindCell(CriticalTableDetail detail, TableContextSnapshot? context)
{
ArgumentNullException.ThrowIfNull(detail);
if (context is null)
{
return null;
}
if (context.ResultId is { } resultId)
{
var matchedByResultId = detail.Cells.FirstOrDefault(cell => cell.ResultId == resultId);
if (matchedByResultId is not null)
{
return matchedByResultId;
}
}
if (string.IsNullOrWhiteSpace(context.RollBand) && string.IsNullOrWhiteSpace(context.ColumnKey) && string.IsNullOrWhiteSpace(context.GroupKey))
{
return null;
}
return detail.Cells.FirstOrDefault(cell => string.Equals(cell.RollBand, context.RollBand, StringComparison.Ordinal) && string.Equals(cell.ColumnKey, context.ColumnKey, StringComparison.Ordinal) && string.Equals(cell.GroupKey ?? string.Empty, context.GroupKey ?? string.Empty, StringComparison.Ordinal));
}
public static CriticalTableCellDetail? FindFirstUncurated(CriticalTableDetail detail) =>
GetOrderedCells(detail).FirstOrDefault(cell => !cell.IsCurated);
public static CriticalTableCellDetail? FindNextUncurated(CriticalTableDetail detail, int currentResultId)
{
var orderedCells = GetOrderedCells(detail);
var currentIndex = orderedCells.Select((cell, index) => new
{
cell.ResultId,
index
}).FirstOrDefault(item => item.ResultId == currentResultId)?.index ?? -1;
for (var index = currentIndex + 1; index < orderedCells.Count; index++)
{
if (!orderedCells[index].IsCurated)
{
return orderedCells[index];
}
}
return null;
}
public static CurationQueueItem CreateQueueItem(CriticalTableDetail detail, CriticalTableCellDetail cell)
{
ArgumentNullException.ThrowIfNull(detail);
ArgumentNullException.ThrowIfNull(cell);
return new CurationQueueItem(detail.Slug, detail.DisplayName, cell.ResultId, cell.RollBand, cell.ColumnKey, cell.ColumnLabel, cell.GroupKey, cell.GroupLabel);
}
}

View File

@@ -0,0 +1,16 @@
namespace RolemasterDb.App.Frontend.Curation;
public static class CurationQueueScopes
{
public const string AllTables = "all";
public const string SelectedTable = "table";
public const string PinnedSet = "pinned";
public static string Normalize(string? value) =>
value?.Trim().ToLowerInvariant() switch
{
SelectedTable => SelectedTable,
PinnedSet => PinnedSet,
_ => AllTables
};
}

View File

@@ -2306,6 +2306,66 @@ select.input-shell {
gap: 1rem;
}
.curation-page {
display: grid;
gap: 1rem;
}
.curation-queue-bar,
.curation-workspace-frame,
.curation-empty-state {
gap: 1rem;
}
.curation-queue-bar-header,
.curation-queue-links,
.curation-queue-summary,
.curation-workspace-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
.curation-queue-bar-header {
justify-content: space-between;
}
.curation-queue-heading {
display: grid;
gap: 0.25rem;
}
.curation-scope-tabs {
width: fit-content;
max-width: 100%;
}
.curation-table-select {
max-width: min(26rem, 100%);
}
.curation-queue-summary {
color: var(--text-secondary);
}
.curation-queue-summary strong {
color: var(--text-primary);
}
.curation-workspace-shell {
display: grid;
gap: 1rem;
}
.curation-workspace-actions {
justify-content: flex-end;
}
.curation-empty-state {
justify-items: start;
}
.diagnostics-page-header {
display: flex;
align-items: start;
@@ -2543,7 +2603,11 @@ select.input-shell {
}
.diagnostics-page-header,
.diagnostics-selection-summary {
.diagnostics-selection-summary,
.curation-queue-bar-header,
.curation-queue-links,
.curation-queue-summary,
.curation-workspace-actions {
flex-direction: column;
align-items: stretch;
}

View File

@@ -0,0 +1,64 @@
using RolemasterDb.App.Features;
using RolemasterDb.App.Frontend.AppState;
using RolemasterDb.App.Frontend.Curation;
namespace RolemasterDb.ImportTool.Tests;
public sealed class CurationQueueResolverTests
{
[Fact]
public void Ordered_cells_follow_roll_group_and_column_sort_order()
{
var detail = CreateDetail(new CriticalTableCellDetail(4, "21-25", "C", "C", "severity", "B", "Beta", false, null, [], []), new CriticalTableCellDetail(1, "01-05", "B", "B", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(3, "21-25", "A", "A", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(2, "21-25", "B", "B", "severity", "A", "Alpha", false, null, [], []));
var orderedIds = CurationQueueResolver.GetOrderedCells(detail).Select(cell => cell.ResultId).ToArray();
Assert.Equal([1, 3, 2, 4], orderedIds);
}
[Fact]
public void Find_first_uncurated_returns_first_matching_cell_in_order()
{
var detail = CreateDetail(new CriticalTableCellDetail(1, "01-05", "A", "A", "severity", null, null, true, null, [], []), new CriticalTableCellDetail(2, "01-05", "B", "B", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(3, "06-10", "A", "A", "severity", null, null, false, null, [], []));
var nextCell = CurationQueueResolver.FindFirstUncurated(detail);
Assert.NotNull(nextCell);
Assert.Equal(2, nextCell!.ResultId);
}
[Fact]
public void Find_next_uncurated_advances_without_wrapping()
{
var detail = CreateDetail(new CriticalTableCellDetail(1, "01-05", "A", "A", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(2, "01-05", "B", "B", "severity", null, null, true, null, [], []), new CriticalTableCellDetail(3, "06-10", "A", "A", "severity", null, null, false, null, [], []));
Assert.Equal(3, CurationQueueResolver.FindNextUncurated(detail, 1)!.ResultId);
Assert.Null(CurationQueueResolver.FindNextUncurated(detail, 3));
}
[Fact]
public void Find_cell_prefers_result_id_and_falls_back_to_location_context()
{
var detail = CreateDetail(new CriticalTableCellDetail(11, "01-05", "A", "A", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(12, "06-10", "B", "B", "severity", "A", "Alpha", false, null, [], []));
var byResult = CurationQueueResolver.FindCell(detail, new TableContextSnapshot(ResultId: 12));
var byLocation = CurationQueueResolver.FindCell(detail, new TableContextSnapshot(GroupKey: "A", ColumnKey: "B", RollBand: "06-10"));
Assert.Equal(12, byResult!.ResultId);
Assert.Equal(12, byLocation!.ResultId);
}
private static CriticalTableDetail CreateDetail(params CriticalTableCellDetail[] cells) =>
new("slash", "Slash Critical Strike Table", "standard", "Slash.pdf", null, [
new CriticalColumnReference("A", "A", "severity", 1),
new CriticalColumnReference("B", "B", "severity", 2),
new CriticalColumnReference("C", "C", "severity", 3)
], [
new CriticalGroupReference("A", "Alpha", 1),
new CriticalGroupReference("B", "Beta", 2)
], [
new CriticalRollBandReference("01-05", 1, 5, 1),
new CriticalRollBandReference("06-10", 6, 10, 2),
new CriticalRollBandReference("21-25", 21, 25, 3)
], cells, []);
}