490 lines
24 KiB
Plaintext
490 lines
24 KiB
Plaintext
@page "/editor"
|
|
@using Microsoft.AspNetCore.Components.QuickGrid
|
|
@using SchemaEditor.Services
|
|
@using ExcelKv.Core
|
|
@inject ExcelService ExcelService
|
|
@inject GarnetClientService GarnetClient
|
|
@inject IJSRuntime JSRuntime
|
|
@rendermode InteractiveServer
|
|
|
|
<div class="container-fluid editor-page">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h3 class="mb-0">Standard Schema Editor</h3>
|
|
@if (ExcelService.LoadedData.Any())
|
|
{
|
|
<div>
|
|
<span class="badge bg-light text-dark me-2 border">@ExcelService.LoadedData.Count keys</span>
|
|
<button class="btn btn-sm btn-outline-success fw-bold" @onclick="SaveToGarnet">
|
|
<i class="bi bi-hdd-network"></i> Save to DB
|
|
</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@if (!string.IsNullOrEmpty(errorMessage))
|
|
{
|
|
<div class="alert alert-danger alert-dismissible fade show py-2" role="alert">
|
|
<small>@errorMessage</small>
|
|
<button type="button" class="btn-close py-2" @onclick="() => errorMessage = null"></button>
|
|
</div>
|
|
}
|
|
@if (!string.IsNullOrEmpty(successMessage))
|
|
{
|
|
<div class="alert alert-success alert-dismissible fade show py-2" role="alert">
|
|
<small>@successMessage</small>
|
|
<button type="button" class="btn-close py-2" @onclick="() => successMessage = null"></button>
|
|
</div>
|
|
}
|
|
|
|
<!-- 1. Connection Toolbar -->
|
|
<div class="card mb-3 shadow-sm">
|
|
<div class="card-body py-2">
|
|
<div class="row g-2 align-items-center">
|
|
<div class="col-auto"><label class="fw-bold small">File source:</label></div>
|
|
<div class="col-md-5">
|
|
<div class="input-group input-group-sm">
|
|
<select class="form-select" style="max-width: 150px;" value="@filePath" @onchange="OnSampleFileSelected">
|
|
<option value="">(Custom)</option>
|
|
@foreach(var f in sampleFiles)
|
|
{
|
|
<option value="@f">@System.IO.Path.GetFileName(f)</option>
|
|
}
|
|
</select>
|
|
<input class="form-control" @bind="filePath" placeholder="/path/to/file.xlsx" />
|
|
<button class="btn btn-secondary" @onclick="ConnectFile">Connect</button>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto"><label class="fw-bold small ms-2">Sheet:</label></div>
|
|
<div class="col-md-3">
|
|
<select class="form-select form-select-sm" @bind="selectedSheet">
|
|
@if (sheets == null || !sheets.Any()) { <option value="">(None)</option> }
|
|
else
|
|
{
|
|
<option value="">-- Select --</option>
|
|
@foreach (var s in sheets) { <option value="@s">@s</option> }
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-auto">
|
|
<button class="btn btn-primary btn-sm" @onclick="LoadPreview" disabled="@string.IsNullOrEmpty(selectedSheet)">Load Sheet</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if (rawData != null && rawData.Any())
|
|
{
|
|
<!-- 2. Compact Region Config -->
|
|
<div class="card mb-3 border-primary shadow-sm">
|
|
<div class="card-body py-2 bg-light text-primary">
|
|
<div class="row align-items-center g-2">
|
|
<div class="col-auto">
|
|
<span class="badge bg-primary">Top Header</span>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="input-group input-group-sm">
|
|
<span class="input-group-text">Start Row</span>
|
|
<input type="number" class="form-control" style="width: 50px;" @bind="config.TopHeaderStartRow" />
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="input-group input-group-sm">
|
|
<span class="input-group-text">End Row</span>
|
|
<input type="number" class="form-control" style="width: 50px;"
|
|
value="@(config.TopHeaderStartRow + config.TopHeaderDepth - 1)"
|
|
@onchange="@(e => { if(int.TryParse(e.Value?.ToString(), out int val)) config.TopHeaderDepth = Math.Max(1, val - config.TopHeaderStartRow + 1); })" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-auto border-start ps-3 ms-2">
|
|
<span class="badge bg-success">Left Axis</span>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="input-group input-group-sm">
|
|
<span class="input-group-text">Start Col</span>
|
|
<input type="number" class="form-control" style="width: 50px;" @bind="config.LeftHeaderStartCol" />
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="input-group input-group-sm">
|
|
<span class="input-group-text">End Col</span>
|
|
<input type="number" class="form-control" style="width: 50px;"
|
|
value="@(config.LeftHeaderStartCol + config.LeftHeaderWidth - 1)"
|
|
@onchange="@(e => { if(int.TryParse(e.Value?.ToString(), out int val)) config.LeftHeaderWidth = Math.Max(1, val - config.LeftHeaderStartCol + 1); })" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Limits -->
|
|
<div class="col-auto border-start ps-3 ms-2">
|
|
<span class="badge bg-warning text-dark">Limit</span>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="input-group input-group-sm">
|
|
<span class="input-group-text">End Row</span>
|
|
<input type="number" class="form-control" style="width: 50px;" @bind="config.DataEndRow" placeholder="All" />
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="input-group input-group-sm">
|
|
<span class="input-group-text">End Col</span>
|
|
<input type="number" class="form-control" style="width: 50px;" @bind="config.DataEndCol" placeholder="All" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col ms-auto text-end">
|
|
<button class="btn btn-success btn-sm fw-bold px-4" @onclick="ParseData">
|
|
PARSE <i class="bi bi-play-fill"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<!-- 3. Preview Grid (Left) -->
|
|
<div class="col-md-@(ExcelService.LoadedData.Any() ? "6" : "12")">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header py-1 bg-white fw-bold small text-muted">
|
|
RAW PREVIEW
|
|
</div>
|
|
<div class="card-body p-0 preview-scroll table-scroll">
|
|
<table class="table table-bordered table-sm table-hover mb-0 small" style="font-size: 0.8rem;">
|
|
<thead class="table-light sticky-top">
|
|
<tr>
|
|
<th class="text-center sticky-col-header sticky-col sticky-header">#</th>
|
|
@for (int c = 0; c < MaxCols; c++)
|
|
{
|
|
bool isSearchMatch = SearchMatch(c, -1);
|
|
bool isLeftAxis = c >= config.LeftHeaderStartCol && c < config.LeftHeaderStartCol + config.LeftHeaderWidth;
|
|
|
|
<th style="cursor: pointer; min-width: 40px;" class="text-center @(isLeftAxis ? "table-success border-bottom border-success" : "")"
|
|
@onclick="() => SetLeftCol(c)" title="Click to extend Width">
|
|
@c
|
|
@if(isLeftAxis) { <div style="font-size:0.6em">AXIS</div> }
|
|
</th>
|
|
}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@for (int r = 0; r < rawData.Count; r++)
|
|
{
|
|
bool isHeaderRow = r >= config.TopHeaderStartRow && r < config.TopHeaderStartRow + config.TopHeaderDepth;
|
|
bool isDataStartRow = r == config.TopHeaderStartRow + config.TopHeaderDepth;
|
|
bool isLimit = (config.DataEndRow.HasValue && r == config.DataEndRow.Value) || (!config.DataEndRow.HasValue && r == rawData.Count - 1);
|
|
|
|
<tr class="@(isHeaderRow ? "" : "") @(isLimit ? "border-bottom border-3 border-danger" : "")">
|
|
<td style="cursor: pointer;" @onclick="() => SetTopRow(r)" class="text-center fw-bold text-muted bg-light position-relative sticky-col row-index-cell" title="Click to extend Depth">
|
|
@r
|
|
@if(isLimit) { <span class="badge bg-danger text-white" style="font-size:0.6em">END</span> }
|
|
</td>
|
|
@{
|
|
int dataStartCol = config.LeftHeaderStartCol + config.LeftHeaderWidth;
|
|
bool isDataRowRange = r >= (config.TopHeaderStartRow + config.TopHeaderDepth) && (!config.DataEndRow.HasValue || r <= config.DataEndRow.Value);
|
|
}
|
|
@for (int c = 0; c < MaxCols; c++)
|
|
{
|
|
var val = c < rawData[r].Length ? rawData[r][c] : "";
|
|
bool isHighlighted = (r == highlightedRow && c == highlightedCol);
|
|
bool isLimitCol = (config.DataEndCol.HasValue && c == config.DataEndCol.Value);
|
|
bool isLeftAxisCol = c >= config.LeftHeaderStartCol && c < config.LeftHeaderStartCol + config.LeftHeaderWidth;
|
|
bool isDataStartColumn = (c == dataStartCol);
|
|
|
|
// Determine cell style based on region
|
|
string cellClass = "";
|
|
if (isHighlighted) cellClass = "table-active border border-3 border-danger fw-bold";
|
|
else if (isLimitCol) cellClass = "border-end border-3 border-danger";
|
|
else if (isLeftAxisCol) cellClass = "table-light text-primary";
|
|
else if (isHeaderRow && !isLeftAxisCol) cellClass = "table-primary fw-bold text-center";
|
|
|
|
// Feature: Yellow Data Region Start Line
|
|
if (isDataStartColumn && isDataRowRange)
|
|
{
|
|
cellClass += " border-start border-4 border-warning";
|
|
}
|
|
|
|
<td id="@($"cell-{r}-{c}")"
|
|
class="@cellClass position-relative"
|
|
@ondblclick="() => OnPreviewCellDoubleClick(r, c)"
|
|
style="white-space: nowrap; overflow: hidden; max-width: 120px; text-overflow: ellipsis; cursor: pointer;" title="@val">
|
|
|
|
@{
|
|
// Determine if this is the "End" cell
|
|
// Rule: It is the End Row.
|
|
// And it is either the explicit End Col OR the global max column if End Col is null.
|
|
bool isVisualEndCol = false;
|
|
if (isLimit)
|
|
{
|
|
if (config.DataEndCol.HasValue) isVisualEndCol = (c == config.DataEndCol.Value);
|
|
else isVisualEndCol = (c == MaxCols - 1);
|
|
}
|
|
}
|
|
|
|
@if(isDataStartRow && isDataStartColumn)
|
|
{
|
|
<span class="badge bg-warning text-dark" style="font-size:0.5em; position:absolute; top:0; right:0; z-index:10;">START</span>
|
|
}
|
|
@if(isLimit && isVisualEndCol)
|
|
{
|
|
<span class="badge bg-danger text-white" style="font-size:0.5em; position:absolute; bottom:0; right:0; z-index:10;">END</span>
|
|
}
|
|
|
|
@val
|
|
</td>
|
|
}
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 4. Result Grid (Right) -->
|
|
@if (ExcelService.LoadedData.Any())
|
|
{
|
|
<div class="col-md-6">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header py-1 bg-white fw-bold small text-success d-flex justify-content-between align-items-center">
|
|
<span>PARSED RESULT (@FilteredItems.Count() items)</span>
|
|
<div class="input-group input-group-sm w-50">
|
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
|
<input class="form-control" placeholder="Search..." @bind="searchText" @bind:event="oninput" />
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="result-scroll table-scroll">
|
|
<table class="table table-striped table-hover mb-0 small">
|
|
<thead class="sticky-top bg-light">
|
|
<tr>
|
|
<th class="sticky-header sticky-col">Key</th>
|
|
<th class="sticky-header">Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var item in FilteredItems.Take(100))
|
|
{
|
|
<tr id="@($"result-item-{item.Row}-{item.Col}")"
|
|
class="@(highlightedRow == item.Row && highlightedCol == item.Col ? "table-info border border-2 border-info" : "")"
|
|
style="cursor: pointer;"
|
|
@onclick="() => HighlightCell(item.Row, item.Col)">
|
|
<td class="sticky-col" title="@item.Key">@item.Key</td>
|
|
<td>@item.Value</td>
|
|
</tr>
|
|
}
|
|
@if(FilteredItems.Count() > 100) { <tr><td colspan="2" class="text-center text-muted">... showing first 100 of @FilteredItems.Count() (Refine Search) ...</td></tr> }
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@code {
|
|
private string filePath = "";
|
|
private List<string> sampleFiles = new();
|
|
private List<string> sheets = new();
|
|
|
|
protected override void OnInitialized()
|
|
{
|
|
// Robust discovery: Check multiple possible locations
|
|
var currentDir = System.IO.Directory.GetCurrentDirectory();
|
|
|
|
// Candidates:
|
|
// 1. Current Dir (if running from repo root)
|
|
// 2. Parent Dir (if running from SchemaEditor project root)
|
|
// 3. Absolute path fallback (safeguard)
|
|
var candidates = new[] {
|
|
System.IO.Path.Combine(currentDir, "sample_data"),
|
|
System.IO.Path.Combine(currentDir, "..", "sample_data"),
|
|
"/home/lectom/repos/design-bim-dogma/sample_data"
|
|
};
|
|
|
|
bool found = false;
|
|
foreach(var path in candidates)
|
|
{
|
|
if(System.IO.Directory.Exists(path))
|
|
{
|
|
sampleFiles = System.IO.Directory.GetFiles(path, "*.xlsx").ToList();
|
|
if(sampleFiles.Any())
|
|
{
|
|
filePath = sampleFiles.First();
|
|
found = true;
|
|
// Debug info (console or temp ui)
|
|
Console.WriteLine($"[Editor] Found sample_data at: {path}");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if(!found)
|
|
{
|
|
// Fallback for debugging - show where we looked
|
|
errorMessage = $"Warning: sample_data not found. Checked: {string.Join(", ", candidates)} (CWD: {currentDir})";
|
|
}
|
|
}
|
|
|
|
private void OnSampleFileSelected(ChangeEventArgs e)
|
|
{
|
|
var val = e.Value?.ToString();
|
|
if(!string.IsNullOrEmpty(val)) filePath = val;
|
|
}
|
|
private string selectedSheet = "";
|
|
private List<string[]> rawData = new();
|
|
private string? errorMessage;
|
|
private string? successMessage;
|
|
|
|
// Traceability State
|
|
private int highlightedRow = -1;
|
|
private int highlightedCol = -1;
|
|
|
|
private int MaxCols => rawData.Count == 0 ? 0 : rawData.Max(r => r.Length);
|
|
|
|
// Search State
|
|
private string searchText = "";
|
|
|
|
// IQueryable Source for Filter
|
|
private IEnumerable<ParsedItem> FilteredItems =>
|
|
string.IsNullOrWhiteSpace(searchText)
|
|
? ExcelService.LoadedData
|
|
: ExcelService.LoadedData.Where(x =>
|
|
{
|
|
var terms = searchText.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
|
string k = x.Key ?? "";
|
|
string v = x.Value ?? "";
|
|
return terms.All(t => k.Contains(t, StringComparison.OrdinalIgnoreCase) || v.Contains(t, StringComparison.OrdinalIgnoreCase));
|
|
});
|
|
|
|
private RegionConfig config = new RegionConfig
|
|
{
|
|
TopHeaderStartRow = 0,
|
|
TopHeaderDepth = 3,
|
|
LeftHeaderStartCol = 0,
|
|
LeftHeaderWidth = 4
|
|
};
|
|
|
|
private async Task HighlightCell(int r, int c)
|
|
{
|
|
highlightedRow = r;
|
|
highlightedCol = c;
|
|
StateHasChanged();
|
|
try {
|
|
await JSRuntime.InvokeVoidAsync("scrollToElement", $"cell-{r}-{c}");
|
|
} catch { /* Ignore JS errors */ }
|
|
}
|
|
|
|
private async Task OnPreviewCellDoubleClick(int r, int c)
|
|
{
|
|
highlightedRow = r;
|
|
highlightedCol = c;
|
|
|
|
// Reverse Traceability: Find item in LoadedData
|
|
if (ExcelService.LoadedData.Any())
|
|
{
|
|
var match = ExcelService.LoadedData.FirstOrDefault(x => x.Row == r && x.Col == c);
|
|
if (match != null)
|
|
{
|
|
// To ensure the item is visible in the virtualized/limited list, we filter by its Key
|
|
searchText = match.Key;
|
|
StateHasChanged(); // Apply filter
|
|
|
|
// Wait for render then scroll
|
|
await Task.Delay(50);
|
|
try {
|
|
await JSRuntime.InvokeVoidAsync("scrollToElement", $"result-item-{r}-{c}");
|
|
} catch { /* Ignore */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool SearchMatch(int c, int r)
|
|
{
|
|
// Placeholder for future advanced matrix search highlighting if needed
|
|
return false;
|
|
}
|
|
|
|
private async Task ConnectFile()
|
|
{
|
|
errorMessage = null; successMessage = null;
|
|
try
|
|
{
|
|
sheets = await ExcelService.GetSheetsAsync(filePath);
|
|
if(sheets.Any()) selectedSheet = sheets[0];
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
errorMessage = $"Connection Failed: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private async Task LoadPreview()
|
|
{
|
|
if (string.IsNullOrEmpty(selectedSheet)) return;
|
|
errorMessage = null; successMessage = null; highlightedRow = -1;
|
|
try
|
|
{
|
|
rawData = await ExcelService.GetPreviewAsync(filePath, selectedSheet);
|
|
ExcelService.LoadedData.Clear();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errorMessage = $"Preview Failed: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private void SetTopRow(int r)
|
|
{
|
|
if (r >= config.TopHeaderStartRow)
|
|
config.TopHeaderDepth = r - config.TopHeaderStartRow + 1;
|
|
else {
|
|
int oldEnd = config.TopHeaderStartRow + config.TopHeaderDepth - 1;
|
|
config.TopHeaderStartRow = r;
|
|
config.TopHeaderDepth = oldEnd - r + 1;
|
|
}
|
|
}
|
|
|
|
private void SetLeftCol(int c)
|
|
{
|
|
if (c >= config.LeftHeaderStartCol)
|
|
config.LeftHeaderWidth = c - config.LeftHeaderStartCol + 1;
|
|
else {
|
|
int oldEnd = config.LeftHeaderStartCol + config.LeftHeaderWidth - 1;
|
|
config.LeftHeaderStartCol = c;
|
|
config.LeftHeaderWidth = oldEnd - c + 1;
|
|
}
|
|
}
|
|
|
|
private async Task ParseData()
|
|
{
|
|
errorMessage = null; successMessage = null; highlightedRow = -1;
|
|
try
|
|
{
|
|
await ExcelService.LoadFileAsync(filePath, selectedSheet, config);
|
|
successMessage = $"Parsing Complete! {ExcelService.LoadedData.Count} items generated.";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errorMessage = $"Parse Failed: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private async Task SaveToGarnet()
|
|
{
|
|
errorMessage = null; successMessage = null;
|
|
if(!ExcelService.LoadedData.Any()) return;
|
|
|
|
try
|
|
{
|
|
await ExcelService.SaveToStorageAsync(GarnetClient);
|
|
successMessage = $"Saved {ExcelService.LoadedData.Count} items to Garnet.";
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
errorMessage = $"Save Failed: {ex.Message}";
|
|
}
|
|
}
|
|
}
|