그리드 표현 일부 완료. 데이터 검측 필수 예정
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ obj/
|
|||||||
*.log
|
*.log
|
||||||
checkpoints/
|
checkpoints/
|
||||||
sample.txt
|
sample.txt
|
||||||
|
sample_data
|
||||||
|
|||||||
BIN
DB작업_U형측구.xlsx
BIN
DB작업_U형측구.xlsx
Binary file not shown.
@@ -3,31 +3,32 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MiniExcelLibs;
|
using MiniExcelLibs;
|
||||||
using StackExchange.Redis; // We'll just use IDatabase interface if possible, or dependency injection.
|
|
||||||
// For now, let's keep it simple and pass the db/batch or connection string.
|
|
||||||
// Better yet, pass an interface IStorageWrapper.
|
|
||||||
|
|
||||||
namespace ExcelKv.Core;
|
namespace ExcelKv.Core;
|
||||||
|
|
||||||
// Simple Config DTO for user-defined areas
|
|
||||||
public class RegionConfig
|
public class RegionConfig
|
||||||
{
|
{
|
||||||
public int TopHeaderStartRow { get; set; } = 0;
|
public int TopHeaderStartRow { get; set; } = 0;
|
||||||
public int TopHeaderDepth { get; set; } = 3;
|
public int TopHeaderDepth { get; set; } = 3;
|
||||||
public int LeftHeaderStartCol { get; set; } = 0;
|
public int LeftHeaderStartCol { get; set; } = 0;
|
||||||
public int LeftHeaderWidth { get; set; } = 4;
|
public int LeftHeaderWidth { get; set; } = 4;
|
||||||
|
|
||||||
|
// Optional Parsing Limits (inclusive indices)
|
||||||
|
public int? DataEndRow { get; set; }
|
||||||
|
public int? DataEndCol { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IStorageWrapper
|
public interface IStorageWrapper
|
||||||
{
|
{
|
||||||
Task SetAsync(string key, string value);
|
Task SetAsync(string key, string value);
|
||||||
|
// New: Metadata overload for Traceability
|
||||||
|
Task SetAsync(string key, string value, int row, int col);
|
||||||
Task IncrementAsync(string key, double value);
|
Task IncrementAsync(string key, double value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live Aggregator Helper (Moved here for shared access)
|
// Live Aggregator Helper (Moved here for shared access)
|
||||||
public class LiveAggregator
|
public class LiveAggregator
|
||||||
{
|
{
|
||||||
// Simple basic check for now. Can be expanded.
|
|
||||||
private static readonly System.Text.RegularExpressions.Regex MetricPattern =
|
private static readonly System.Text.RegularExpressions.Regex MetricPattern =
|
||||||
new System.Text.RegularExpressions.Regex(@"(수량|합계|Volume|Weight|Total|Usage)", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
|
new System.Text.RegularExpressions.Regex(@"(수량|합계|Volume|Weight|Total|Usage)", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
|
||||||
|
|
||||||
@@ -42,6 +43,25 @@ public class LiveAggregator
|
|||||||
|
|
||||||
public class ExcelLoader
|
public class ExcelLoader
|
||||||
{
|
{
|
||||||
|
public static async Task<List<string>> GetSheetNamesAsync(string filePath)
|
||||||
|
{
|
||||||
|
return await Task.Run(() => MiniExcel.GetSheetNames(filePath).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<List<string[]>> GetPreviewRowsAsync(string filePath, string sheetName, int count = 50)
|
||||||
|
{
|
||||||
|
return await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var rows = MiniExcel.Query(filePath, sheetName: sheetName, useHeaderRow: false).Take(count).ToList();
|
||||||
|
List<string[]> result = new();
|
||||||
|
foreach(IDictionary<string, object> r in rows)
|
||||||
|
{
|
||||||
|
result.Add(FlattenDictionaryRow(r));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<int> ProcessFileAsync(
|
public static async Task<int> ProcessFileAsync(
|
||||||
string filePath,
|
string filePath,
|
||||||
string sheetName,
|
string sheetName,
|
||||||
@@ -50,7 +70,6 @@ public class ExcelLoader
|
|||||||
SchemaRegistry registry)
|
SchemaRegistry registry)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[Core.Loader] Processing {filePath} Sheet: {sheetName}...");
|
Console.WriteLine($"[Core.Loader] Processing {filePath} Sheet: {sheetName}...");
|
||||||
Console.WriteLine($"[Config] Top: Row{config.TopHeaderStartRow} (+{config.TopHeaderDepth}), Left: Col{config.LeftHeaderStartCol} (+{config.LeftHeaderWidth})");
|
|
||||||
|
|
||||||
var rows = MiniExcel.Query(filePath, sheetName: sheetName, useHeaderRow: false).ToList();
|
var rows = MiniExcel.Query(filePath, sheetName: sheetName, useHeaderRow: false).ToList();
|
||||||
|
|
||||||
@@ -58,112 +77,118 @@ public class ExcelLoader
|
|||||||
int dataStartRow = config.TopHeaderStartRow + config.TopHeaderDepth;
|
int dataStartRow = config.TopHeaderStartRow + config.TopHeaderDepth;
|
||||||
if (rows.Count <= dataStartRow) return 0;
|
if (rows.Count <= dataStartRow) return 0;
|
||||||
|
|
||||||
// 1. Analyze Top Headers
|
// 1. Analyze Top Headers (Data Columns)
|
||||||
// Extract the rows that form the Top Header
|
var topHeaderRows = new List<string[]>();
|
||||||
List<string[]> topHeaderRows = new();
|
|
||||||
for (int i = config.TopHeaderStartRow; i < dataStartRow; i++)
|
for (int i = config.TopHeaderStartRow; i < dataStartRow; i++)
|
||||||
{
|
{
|
||||||
var rowDict = (IDictionary<string, object>)rows[i];
|
topHeaderRows.Add(FlattenDictionaryRow((IDictionary<string, object>)rows[i]));
|
||||||
topHeaderRows.Add(FlattenDictionaryRow(rowDict));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten Top Headers (Horizontal Forward Fill)
|
// Calculate Global Max Col from Data Rows to ensure we don't truncate data
|
||||||
// Offset: We effectively care about columns starting from LeftHeaderWidth to End
|
// Optimization: check a sample or assume logical limit. For correctness, check valid rows.
|
||||||
// but we pass the absolute index.
|
int globalMaxCol = 0;
|
||||||
var topAxisKeys = FlattenTopHeaders(topHeaderRows, config.LeftHeaderStartCol + config.LeftHeaderWidth);
|
int limitRow = config.DataEndRow.HasValue ? Math.Min(rows.Count, config.DataEndRow.Value + 1) : rows.Count;
|
||||||
|
for(int i = dataStartRow; i < limitRow; i++)
|
||||||
// Register Schema
|
|
||||||
string ns = System.IO.Path.GetFileName(filePath) + "_" + sheetName;
|
|
||||||
registry.RegisterSchema(ns, topAxisKeys);
|
|
||||||
|
|
||||||
// 2. Process Data Rows
|
|
||||||
// State for Vertical Forward Fill (Left Headers)
|
|
||||||
string[] lastLeftValues = new string[config.LeftHeaderWidth];
|
|
||||||
|
|
||||||
int processedCount = 0;
|
|
||||||
|
|
||||||
for (int i = dataStartRow; i < rows.Count; i++)
|
|
||||||
{
|
{
|
||||||
var rowDict = (IDictionary<string, object>)rows[i];
|
var d = (IDictionary<string, object>)rows[i];
|
||||||
var rowVals = FlattenDictionaryRow(rowDict);
|
if(d.Count > globalMaxCol) globalMaxCol = d.Count; // Approximate
|
||||||
|
// FlattenDictionaryRow is cleaner but expensive to call just for count.
|
||||||
|
// d.Keys.Count is effectively the column count for that row.
|
||||||
|
}
|
||||||
|
|
||||||
// A. Build Left Axis Key (Vertical Forward Fill)
|
// Flatten Data Headers (Right Side)
|
||||||
var currentLeftParts = new List<string>();
|
var topAxisKeys = FlattenTopHeaders(topHeaderRows, config.LeftHeaderStartCol + config.LeftHeaderWidth, globalMaxCol);
|
||||||
bool rowHasContent = false;
|
|
||||||
|
// ... (Left Axis Header logic omitted for brevity, unchanged) ...
|
||||||
|
// ** New: Extract Left Axis Headers (Corner Region) **
|
||||||
|
// These are the headers *above* the Left Key columns.
|
||||||
|
var leftAxisHeaders = new List<string>();
|
||||||
|
var bottomHeaderRow = FlattenDictionaryRow((IDictionary<string, object>)rows[dataStartRow - 1]);
|
||||||
|
|
||||||
|
for (int c = 0; c < config.LeftHeaderWidth; c++)
|
||||||
|
{
|
||||||
|
int absCol = config.LeftHeaderStartCol + c;
|
||||||
|
string headerVal = (absCol < bottomHeaderRow.Length) ? bottomHeaderRow[absCol] : $"Col{c}";
|
||||||
|
leftAxisHeaders.Add(headerVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
string ns = System.IO.Path.GetFileName(filePath) + "_" + sheetName;
|
||||||
|
registry.RegisterSchema(ns, topAxisKeys);
|
||||||
|
|
||||||
|
// 2. Process Data Rows
|
||||||
|
string[] lastLeftValues = new string[config.LeftHeaderWidth];
|
||||||
|
|
||||||
|
int processedCount = 0;
|
||||||
|
|
||||||
|
for (int i = dataStartRow; i < limitRow; i++)
|
||||||
|
{
|
||||||
|
var rowDict = (IDictionary<string, object>)rows[i];
|
||||||
|
var rowVals = FlattenDictionaryRow(rowDict);
|
||||||
|
|
||||||
|
// ... (Left Key Logic Unchanged) ...
|
||||||
|
var currentLeftParts = new List<string>();
|
||||||
|
bool rowHasContent = false;
|
||||||
for (int c = 0; c < config.LeftHeaderWidth; c++)
|
for (int c = 0; c < config.LeftHeaderWidth; c++)
|
||||||
{
|
{
|
||||||
int absCol = config.LeftHeaderStartCol + c;
|
int absCol = config.LeftHeaderStartCol + c;
|
||||||
string val = (absCol < rowVals.Length) ? rowVals[absCol] : "";
|
string val = (absCol < rowVals.Length) ? rowVals[absCol] : "";
|
||||||
|
if (string.IsNullOrWhiteSpace(val)) val = lastLeftValues[c];
|
||||||
// Logic: Unmerge & Fill (Same as Vertical Forward Fill)
|
else lastLeftValues[c] = val;
|
||||||
if (string.IsNullOrWhiteSpace(val))
|
if(!string.IsNullOrWhiteSpace(val)) {
|
||||||
{
|
|
||||||
val = lastLeftValues[c];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
lastLeftValues[c] = val; // New merge block starts
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!string.IsNullOrWhiteSpace(val))
|
|
||||||
{
|
|
||||||
currentLeftParts.Add(val);
|
currentLeftParts.Add(val);
|
||||||
rowHasContent = true;
|
rowHasContent = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip rows with no left context (e.g. empty separator lines)
|
|
||||||
if (!rowHasContent) continue;
|
if (!rowHasContent) continue;
|
||||||
|
string leftKey = string.Join("----", currentLeftParts);
|
||||||
|
|
||||||
string leftKey = string.Join(".", currentLeftParts);
|
// B. Map Values
|
||||||
|
int limitCol = config.DataEndCol.HasValue ? Math.Min(rowVals.Length, config.DataEndCol.Value + 1) : rowVals.Length;
|
||||||
|
|
||||||
// B. Map Values to Top Axis Keys
|
for (int c = config.LeftHeaderStartCol + config.LeftHeaderWidth; c < limitCol; c++)
|
||||||
for (int c = config.LeftHeaderStartCol + config.LeftHeaderWidth; c < rowVals.Length; c++)
|
|
||||||
{
|
{
|
||||||
// Align physical column to Top Axis Index
|
|
||||||
// Top Axis Keys[0] corresponds to column (LeftStart + LeftWidth)
|
|
||||||
int topIndex = c - (config.LeftHeaderStartCol + config.LeftHeaderWidth);
|
int topIndex = c - (config.LeftHeaderStartCol + config.LeftHeaderWidth);
|
||||||
|
|
||||||
if (topIndex < 0 || topIndex >= topAxisKeys.Count) continue;
|
if (topIndex < 0 || topIndex >= topAxisKeys.Count) continue;
|
||||||
|
|
||||||
string topKey = topAxisKeys[topIndex];
|
string topKey = topAxisKeys[topIndex];
|
||||||
|
if (string.IsNullOrWhiteSpace(topKey)) continue; // Skip if no header key
|
||||||
|
|
||||||
string val = rowVals[c];
|
string val = rowVals[c];
|
||||||
|
if (string.IsNullOrWhiteSpace(val)) continue;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(val)) continue; // Sparse
|
// ** Feature: Rounding to 6 Decimal Places **
|
||||||
|
// "All data should be formatted to 6 decimal places by default"
|
||||||
|
if (double.TryParse(val, out double dVal))
|
||||||
|
{
|
||||||
|
val = Math.Round(dVal, 6).ToString(); // Simple G-format or fixed? User said "Decimal 6 places".
|
||||||
|
// Let's use standard string representation of the rounded 6-digit number to avoid trailing zeros "1.100000".
|
||||||
|
// Math.Round(1.1, 6) -> 1.1.
|
||||||
|
// If user meant "Fixed 6 places" (1.100000), use F6. Usually "representation" means significant digits.
|
||||||
|
// Given SI context, significant precision matters. Let's use normalization (remove trailing zeros)
|
||||||
|
// Update: User said "소수점 6자리까지 표현하는걸로 통일". Could mean truncate or round. Safe bet is Round.
|
||||||
|
}
|
||||||
|
|
||||||
// C. Construct Final Key
|
|
||||||
string fullKey = $"{sheetName}:{leftKey}*{topKey}";
|
string fullKey = $"{sheetName}:{leftKey}*{topKey}";
|
||||||
|
|
||||||
await storage.SetAsync(fullKey, val);
|
await storage.SetAsync(fullKey, val, i, c);
|
||||||
|
|
||||||
// D. Live Compute
|
if (LiveAggregator.IsMetric(topKey) && double.TryParse(val, out double statVal))
|
||||||
if (LiveAggregator.IsMetric(topKey) && double.TryParse(val, out double dVal))
|
|
||||||
{
|
{
|
||||||
await storage.IncrementAsync($"Stats:Global:{topKey}", dVal);
|
await storage.IncrementAsync($"Stats:Global:{topKey}", statVal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
processedCount++;
|
processedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[Core.Loader] Finished. Processed: {processedCount} items.");
|
||||||
return processedCount;
|
return processedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static string[] FlattenDictionaryRow(IDictionary<string, object> rowDict)
|
private static string[] FlattenDictionaryRow(IDictionary<string, object> rowDict)
|
||||||
{
|
{
|
||||||
// MiniExcel Dynamic Dictionary Keys are A, B... AA..
|
|
||||||
// We assume we want 0-indexed string array.
|
|
||||||
// We find the 'Max' column index to allocate array.
|
|
||||||
|
|
||||||
// This sorting + re-indexing is a bit naive for very wide sheets but OK for PoC.
|
|
||||||
// Better to use MiniExcel's column index if available, but dynamic object hides it.
|
|
||||||
// We'll trust the Key sorting for now.
|
|
||||||
var sortedKeys = rowDict.Keys.OrderBy(k => k.Length).ThenBy(k => k).ToList();
|
var sortedKeys = rowDict.Keys.OrderBy(k => k.Length).ThenBy(k => k).ToList();
|
||||||
var result = new string[sortedKeys.Count];
|
var result = new string[sortedKeys.Count];
|
||||||
// Note: Dictionary might define "A", "C" but skip "B".
|
|
||||||
// This simple list approach collapses them.
|
|
||||||
// TO BE EXACT: We should convert "A"->0, "B"->1.
|
|
||||||
// For PoC we keep this, but in production we need ColumnLetterToIndex logic.
|
|
||||||
|
|
||||||
for (int i = 0; i < sortedKeys.Count; i++)
|
for (int i = 0; i < sortedKeys.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -173,15 +198,16 @@ public class ExcelLoader
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<string> FlattenTopHeaders(List<string[]> headerRows, int startCol)
|
private static List<string> FlattenTopHeaders(List<string[]> headerRows, int startCol, int globalMaxCol)
|
||||||
{
|
{
|
||||||
if (headerRows.Count == 0) return new List<string>();
|
if (headerRows.Count == 0) return new List<string>();
|
||||||
int maxCol = headerRows.Max(r => r.Length);
|
// Ensure we cover all data columns, even if headers are short
|
||||||
|
int maxCol = Math.Max(headerRows.Max(r => r.Length), globalMaxCol);
|
||||||
|
|
||||||
var flatHeaders = new List<string>();
|
var flatHeaders = new List<string>();
|
||||||
|
|
||||||
var lastValues = new string[headerRows.Count]; // Last value for each ROW level (Horizontal Fill)
|
|
||||||
|
|
||||||
// Iterate Columns
|
// Iterate Columns
|
||||||
|
var lastValues = new string[headerRows.Count];
|
||||||
for (int c = startCol; c < maxCol; c++)
|
for (int c = startCol; c < maxCol; c++)
|
||||||
{
|
{
|
||||||
var parts = new List<string>();
|
var parts = new List<string>();
|
||||||
@@ -190,19 +216,17 @@ public class ExcelLoader
|
|||||||
{
|
{
|
||||||
string val = (c < headerRows[r].Length) ? headerRows[r][c] : "";
|
string val = (c < headerRows[r].Length) ? headerRows[r][c] : "";
|
||||||
|
|
||||||
// Horizontal Forward Fill (Unmerge Logic)
|
// Horizontal Forward Fill (Re-enabled for Merged Headers)
|
||||||
if (string.IsNullOrWhiteSpace(val))
|
if (string.IsNullOrWhiteSpace(val)) val = lastValues[r];
|
||||||
{
|
else lastValues[r] = val;
|
||||||
val = lastValues[r];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
lastValues[r] = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(val)) parts.Add(val);
|
if (!string.IsNullOrWhiteSpace(val)) parts.Add(val);
|
||||||
}
|
}
|
||||||
flatHeaders.Add(string.Join(".", parts));
|
// If empty, use "Col_Index" fallback or keep empty?
|
||||||
|
// User schema usually requires keys. If empty, it's skipped in mapping.
|
||||||
|
// Let's keep it empty, but if data exists, it won't map unless we have a key.
|
||||||
|
// If parts is empty, let's leave valid empty string so mapping can decide.
|
||||||
|
flatHeaders.Add(string.Join("----", parts));
|
||||||
}
|
}
|
||||||
return flatHeaders;
|
return flatHeaders;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,8 +124,6 @@ class Program
|
|||||||
|
|
||||||
client.ExecuteBatch(); // Commit
|
client.ExecuteBatch(); // Commit
|
||||||
|
|
||||||
// Verification Dump
|
|
||||||
DumpSample(sheetName);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -135,25 +133,4 @@ class Program
|
|||||||
server.SaveCheckpoint();
|
server.SaveCheckpoint();
|
||||||
Console.WriteLine("Done.");
|
Console.WriteLine("Done.");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void DumpSample(string sheetName)
|
|
||||||
{
|
|
||||||
Console.WriteLine("\n--- Exporting to sample.txt ---");
|
|
||||||
using var redis = StackExchange.Redis.ConnectionMultiplexer.Connect("localhost:3278");
|
|
||||||
var serverEnd = redis.GetServer("localhost:3278");
|
|
||||||
var db = redis.GetDatabase();
|
|
||||||
var keys = serverEnd.Keys(pattern: $"{sheetName}:*");
|
|
||||||
|
|
||||||
using (var writer = new StreamWriter("sample.txt"))
|
|
||||||
{
|
|
||||||
int count = 0;
|
|
||||||
foreach(var key in keys)
|
|
||||||
{
|
|
||||||
string val = db.StringGet(key);
|
|
||||||
writer.WriteLine($"{key} = {val}");
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
Console.WriteLine($"[Export] Written {count} keys to sample.txt");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
<body>
|
<body>
|
||||||
<Routes />
|
<Routes />
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
|
<script>
|
||||||
|
window.scrollToElement = (elementId) => {
|
||||||
|
var element = document.getElementById(elementId);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
||||||
|
element.style.transition = "background-color 1s";
|
||||||
|
// Optional: Flash effect logic could go here
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,30 +1,39 @@
|
|||||||
<div class="top-row ps-3 navbar navbar-dark">
|
<div class="top-row ps-3 navbar navbar-dark">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="">SchemaEditor</a>
|
<a class="navbar-brand" href="">SchemaEditor</a>
|
||||||
|
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
|
||||||
|
|
||||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
|
||||||
<nav class="flex-column">
|
<nav class="flex-column">
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
|
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="counter">
|
<NavLink class="nav-link" href="editor">
|
||||||
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
|
<span class="bi bi-table-view" aria-hidden="true"></span> Editor
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="weather">
|
<NavLink class="nav-link" href="data">
|
||||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
|
<span class="bi bi-database" aria-hidden="true"></span> Data Explorer
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool collapseNavMenu = true;
|
||||||
|
|
||||||
|
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
|
||||||
|
|
||||||
|
private void ToggleNavMenu()
|
||||||
|
{
|
||||||
|
collapseNavMenu = !collapseNavMenu;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
@page "/counter"
|
|
||||||
@rendermode InteractiveServer
|
|
||||||
|
|
||||||
<PageTitle>Counter</PageTitle>
|
|
||||||
|
|
||||||
<h1>Counter</h1>
|
|
||||||
|
|
||||||
<p role="status">Current count: @currentCount</p>
|
|
||||||
|
|
||||||
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private int currentCount = 0;
|
|
||||||
|
|
||||||
private void IncrementCount()
|
|
||||||
{
|
|
||||||
currentCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
80
SchemaEditor/Components/Pages/DataExplorer.razor
Normal file
80
SchemaEditor/Components/Pages/DataExplorer.razor
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
@page "/data"
|
||||||
|
@using Microsoft.AspNetCore.Components.QuickGrid
|
||||||
|
@using SchemaEditor.Services
|
||||||
|
@inject GarnetClientService GarnetClient
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<h1 class="mb-4">Data Explorer (Garnet)</h1>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0">Query Store</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">Key Pattern</span>
|
||||||
|
<input class="form-control" @bind="searchPattern" placeholder="e.g. *, SheetName:*" />
|
||||||
|
<button class="btn btn-primary" @onclick="SearchKeys">Search</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Using SCAN/KEYS command. Use specific patterns for performance.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (errorMsg != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@errorMsg</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults != null)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between">
|
||||||
|
<h5 class="mb-0">Results</h5>
|
||||||
|
<span class="badge bg-secondary">@searchResults.Count keys found</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="grid-container" style="height: 600px; overflow-y: auto;">
|
||||||
|
<QuickGrid Items="@searchResults.AsQueryable()" Pagination="@pagination" Class="table table-striped table-hover mb-0">
|
||||||
|
<PropertyColumn Property="@(p => p.Key)" Sortable="true" Title="Key" />
|
||||||
|
<PropertyColumn Property="@(p => p.Value)" Sortable="true" Title="Value" />
|
||||||
|
</QuickGrid>
|
||||||
|
</div>
|
||||||
|
<div class="p-2 border-top">
|
||||||
|
<Paginator State="@pagination" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string searchPattern = "*";
|
||||||
|
private List<KeyValuePair<string, string>>? searchResults;
|
||||||
|
private string? errorMsg;
|
||||||
|
private PaginationState pagination = new PaginationState { ItemsPerPage = 20 };
|
||||||
|
|
||||||
|
private async Task SearchKeys()
|
||||||
|
{
|
||||||
|
errorMsg = null;
|
||||||
|
searchResults = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var keys = await GarnetClient.SearchKeysAsync(searchPattern);
|
||||||
|
var list = new List<KeyValuePair<string, string>>();
|
||||||
|
|
||||||
|
// Fetch values (Bulk get would be better but keeping simple for now)
|
||||||
|
// Limit to 1000 for safety in PoC UI
|
||||||
|
foreach (var k in keys.Take(1000))
|
||||||
|
{
|
||||||
|
var val = await GarnetClient.GetValueAsync(k);
|
||||||
|
list.Add(new KeyValuePair<string, string>(k, val));
|
||||||
|
}
|
||||||
|
searchResults = list;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMsg = $"Query failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
486
SchemaEditor/Components/Pages/Editor.razor
Normal file
486
SchemaEditor/Components/Pages/Editor.razor
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
@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">
|
||||||
|
<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" style="overflow: auto; max-height: 600px;">
|
||||||
|
<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">#</th>
|
||||||
|
@for (int c = 0; c < (rawData.FirstOrDefault()?.Length ?? 0); 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" title="Click to extend Depth">
|
||||||
|
@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 < rawData[r].Length; c++)
|
||||||
|
{
|
||||||
|
var val = 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 last column in the row if End Col is null.
|
||||||
|
bool isVisualEndCol = false;
|
||||||
|
if (isLimit)
|
||||||
|
{
|
||||||
|
if (config.DataEndCol.HasValue) isVisualEndCol = (c == config.DataEndCol.Value);
|
||||||
|
else isVisualEndCol = (c == rawData[r].Length - 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 style="height: 560px; overflow-y: auto;">
|
||||||
|
<table class="table table-striped table-hover mb-0 small">
|
||||||
|
<thead class="sticky-top bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>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 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;
|
||||||
|
|
||||||
|
// 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
@page "/weather"
|
|
||||||
@attribute [StreamRendering]
|
|
||||||
|
|
||||||
<PageTitle>Weather</PageTitle>
|
|
||||||
|
|
||||||
<h1>Weather</h1>
|
|
||||||
|
|
||||||
<p>This component demonstrates showing data.</p>
|
|
||||||
|
|
||||||
@if (forecasts == null)
|
|
||||||
{
|
|
||||||
<p><em>Loading...</em></p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Temp. (C)</th>
|
|
||||||
<th>Temp. (F)</th>
|
|
||||||
<th>Summary</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var forecast in forecasts)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@forecast.Date.ToShortDateString()</td>
|
|
||||||
<td>@forecast.TemperatureC</td>
|
|
||||||
<td>@forecast.TemperatureF</td>
|
|
||||||
<td>@forecast.Summary</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private WeatherForecast[]? forecasts;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
// Simulate asynchronous loading to demonstrate streaming rendering
|
|
||||||
await Task.Delay(500);
|
|
||||||
|
|
||||||
var startDate = DateOnly.FromDateTime(DateTime.Now);
|
|
||||||
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
|
|
||||||
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
|
||||||
{
|
|
||||||
Date = startDate.AddDays(index),
|
|
||||||
TemperatureC = Random.Shared.Next(-20, 55),
|
|
||||||
Summary = summaries[Random.Shared.Next(summaries.Length)]
|
|
||||||
}).ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private class WeatherForecast
|
|
||||||
{
|
|
||||||
public DateOnly Date { get; set; }
|
|
||||||
public int TemperatureC { get; set; }
|
|
||||||
public string? Summary { get; set; }
|
|
||||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,3 +8,4 @@
|
|||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using SchemaEditor
|
@using SchemaEditor
|
||||||
@using SchemaEditor.Components
|
@using SchemaEditor.Components
|
||||||
|
@using Microsoft.AspNetCore.Components.QuickGrid
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents();
|
.AddInteractiveServerComponents();
|
||||||
|
|
||||||
|
builder.Services.AddScoped<SchemaEditor.Services.ExcelService>();
|
||||||
|
builder.Services.AddScoped<SchemaEditor.Services.GarnetClientService>();
|
||||||
|
builder.Services.AddHostedService<SchemaEditor.Services.GarnetHost>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
|
|||||||
@@ -6,4 +6,14 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ExcelKv.Core\ExcelKv.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Garnet" Version="1.0.91" />
|
||||||
|
<PackageReference Include="StackExchange.Redis" Version="2.10.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
73
SchemaEditor/Services/ExcelService.cs
Normal file
73
SchemaEditor/Services/ExcelService.cs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ExcelKv.Core;
|
||||||
|
|
||||||
|
namespace SchemaEditor.Services;
|
||||||
|
|
||||||
|
public record ParsedItem(string Key, string Value, int Row, int Col);
|
||||||
|
|
||||||
|
public class InMemoryStorage : IStorageWrapper
|
||||||
|
{
|
||||||
|
public ConcurrentDictionary<string, ParsedItem> Data { get; private set; } = new();
|
||||||
|
|
||||||
|
public Task SetAsync(string key, string value)
|
||||||
|
{
|
||||||
|
Data[key] = new ParsedItem(key, value, -1, -1);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SetAsync(string key, string value, int row, int col)
|
||||||
|
{
|
||||||
|
Data[key] = new ParsedItem(key, value, row, col);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task IncrementAsync(string key, double value)
|
||||||
|
{
|
||||||
|
Data[key] = new ParsedItem(key, $"[Metric] {value} (Accumulated)", -1, -1);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ParsedItem> GetEntries()
|
||||||
|
{
|
||||||
|
return Data.Values.OrderBy(x => x.Key).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExcelService
|
||||||
|
{
|
||||||
|
// Changed to List<ParsedItem> for metadata access
|
||||||
|
public List<ParsedItem> LoadedData { get; private set; } = new();
|
||||||
|
|
||||||
|
public async Task<List<string>> GetSheetsAsync(string filePath)
|
||||||
|
{
|
||||||
|
return await ExcelLoader.GetSheetNamesAsync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string[]>> GetPreviewAsync(string filePath, string sheetName)
|
||||||
|
{
|
||||||
|
// Increased limit to 1000 to ensure highlighting works for larger files
|
||||||
|
return await ExcelLoader.GetPreviewRowsAsync(filePath, sheetName, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LoadFileAsync(string filePath, string sheetName, RegionConfig config)
|
||||||
|
{
|
||||||
|
var storage = new InMemoryStorage();
|
||||||
|
var registry = new SchemaRegistry();
|
||||||
|
|
||||||
|
await ExcelLoader.ProcessFileAsync(filePath, sheetName, config, storage, registry);
|
||||||
|
|
||||||
|
LoadedData = storage.GetEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveToStorageAsync(IStorageWrapper targetStorage)
|
||||||
|
{
|
||||||
|
foreach (var entry in LoadedData)
|
||||||
|
{
|
||||||
|
await targetStorage.SetAsync(entry.Key, entry.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
SchemaEditor/Services/GarnetClientService.cs
Normal file
68
SchemaEditor/Services/GarnetClientService.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
using ExcelKv.Core;
|
||||||
|
|
||||||
|
namespace SchemaEditor.Services;
|
||||||
|
|
||||||
|
public class GarnetClientService : IStorageWrapper, IDisposable
|
||||||
|
{
|
||||||
|
private ConnectionMultiplexer _redis;
|
||||||
|
private IDatabase _db;
|
||||||
|
|
||||||
|
public bool IsConnected => _redis != null && _redis.IsConnected;
|
||||||
|
|
||||||
|
public void Connect(string connectionString = "localhost:3187")
|
||||||
|
{
|
||||||
|
if (_redis == null || !_redis.IsConnected)
|
||||||
|
{
|
||||||
|
_redis = ConnectionMultiplexer.Connect(connectionString);
|
||||||
|
_db = _redis.GetDatabase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetAsync(string key, string value)
|
||||||
|
{
|
||||||
|
if (_db == null) Connect();
|
||||||
|
await _db.StringSetAsync(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetAsync(string key, string value, int row, int col)
|
||||||
|
{
|
||||||
|
// For Garnet, we currently only store Key-Value string.
|
||||||
|
// Traceability metadata (row, col) could be stored in a hash or side key if needed.
|
||||||
|
// For now, we just proceed with standard storage.
|
||||||
|
if (_db == null) Connect();
|
||||||
|
await _db.StringSetAsync(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task IncrementAsync(string key, double value)
|
||||||
|
{
|
||||||
|
if (_db == null) Connect();
|
||||||
|
await _db.StringIncrementAsync(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Data Explorer
|
||||||
|
public async Task<List<string>> SearchKeysAsync(string pattern)
|
||||||
|
{
|
||||||
|
if (_db == null) Connect();
|
||||||
|
var server = _redis.GetServer(_redis.GetEndPoints().First());
|
||||||
|
// Use Keys for simplicity in Schema Editor (low traffic)
|
||||||
|
// In high production, use SCAN
|
||||||
|
var keys = server.Keys(pattern: pattern).Select(k => k.ToString()).ToList();
|
||||||
|
return await Task.FromResult(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetValueAsync(string key)
|
||||||
|
{
|
||||||
|
if (_db == null) Connect();
|
||||||
|
return await _db.StringGetAsync(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_redis?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
40
SchemaEditor/Services/GarnetHost.cs
Normal file
40
SchemaEditor/Services/GarnetHost.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Garnet;
|
||||||
|
|
||||||
|
namespace SchemaEditor.Services;
|
||||||
|
|
||||||
|
public class GarnetHost : IHostedService, IDisposable
|
||||||
|
{
|
||||||
|
private GarnetServer? _server;
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var serverArgs = new string[] { "--port", "3187" }; // Changed to 3187
|
||||||
|
_server = new GarnetServer(serverArgs);
|
||||||
|
_server.Start();
|
||||||
|
Console.WriteLine("[GarnetHost] Server started on port 3278");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[GarnetHost] Failed to start: {ex.Message}");
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_server?.Dispose();
|
||||||
|
Console.WriteLine("[GarnetHost] Server stopped");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_server?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user