merge wt/wp5 into v3/batch2-raw-ui (Wave B UI)
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
@* CSV tag export for a Raw-tree Device / TagGroup (B2-WP5).
|
||||
|
||||
Reads the device's tags (via the WP5-owned RawTagCsvExportReader — no IRawTreeService extension) and
|
||||
renders them in the same column shape RawCsvImportModal consumes (T0-2 CsvWriter, per-driver
|
||||
CsvColumnMap). The CSV is offered as a self-contained data-URI download anchor (no JS interop, no
|
||||
external host — matches the app's CSP posture) plus a preview textarea.
|
||||
|
||||
Modal contract (the RawTree coordinator wires this in place of the OnExportCsv stub):
|
||||
<RawCsvExportModal @bind-Visible="_exportVisible"
|
||||
DeviceId="@_exportDeviceId" (may be blank if TagGroupId is supplied)
|
||||
TagGroupId="@_exportTagGroupId" (when set, only that group's subtree is exported)
|
||||
DriverType="@_exportDriverType" /> *@
|
||||
@using System.Text
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@inject RawTagCsvExportReader ExportReader
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Export tags — CSV</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (_loading)
|
||||
{
|
||||
<p class="text-muted"><span class="spinner-border spinner-border-sm"></span> Reading tags…</p>
|
||||
}
|
||||
else if (_error is not null)
|
||||
{
|
||||
<div class="alert alert-danger">@_error</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">
|
||||
<span class="badge bg-secondary">@_rowCount tags</span>
|
||||
for <span class="mono">@DriverType</span> device
|
||||
@if (!string.IsNullOrEmpty(_groupFilter))
|
||||
{
|
||||
<span>, group <span class="mono">@_groupFilter</span></span>
|
||||
}
|
||||
</p>
|
||||
<textarea class="form-control mono small" rows="10" readonly>@_csv</textarea>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Close</button>
|
||||
@if (!_loading && _error is null)
|
||||
{
|
||||
<a class="btn btn-primary" download="@_fileName" href="@_dataUri">Download CSV</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the modal is shown (two-way bound).</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>Raised when <see cref="Visible"/> changes (supports <c>@bind-Visible</c>).</summary>
|
||||
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||
|
||||
/// <summary>The device to export. May be blank when <see cref="TagGroupId"/> is supplied.</summary>
|
||||
[Parameter] public string DeviceId { get; set; } = "";
|
||||
|
||||
/// <summary>When set, only tags under this group's subtree are exported; null exports the whole device.</summary>
|
||||
[Parameter] public string? TagGroupId { get; set; }
|
||||
|
||||
/// <summary>The device's driver-type string — selects the per-driver typed column set.</summary>
|
||||
[Parameter] public string DriverType { get; set; } = "";
|
||||
|
||||
private bool _loading;
|
||||
private bool _built;
|
||||
private string? _error;
|
||||
private string _csv = "";
|
||||
private string _dataUri = "";
|
||||
private string _fileName = "tags.csv";
|
||||
private int _rowCount;
|
||||
private string? _groupFilter;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (Visible && !_built)
|
||||
{
|
||||
_built = true;
|
||||
await BuildAsync();
|
||||
}
|
||||
else if (!Visible)
|
||||
{
|
||||
_built = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BuildAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var deviceId = DeviceId;
|
||||
_groupFilter = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(TagGroupId))
|
||||
{
|
||||
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
|
||||
if (ctx is { } c)
|
||||
{
|
||||
_groupFilter = c.GroupPath;
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
deviceId = c.DeviceId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_error = "Could not resolve the target device.";
|
||||
return;
|
||||
}
|
||||
|
||||
var tags = await ExportReader.ReadDeviceTagsAsync(deviceId);
|
||||
|
||||
// When exporting a single group, keep only that group's subtree (its path + descendants).
|
||||
if (!string.IsNullOrEmpty(_groupFilter))
|
||||
{
|
||||
var prefix = _groupFilter;
|
||||
tags = tags
|
||||
.Where(t => t.TagGroupPath is not null &&
|
||||
(t.TagGroupPath == prefix || t.TagGroupPath.StartsWith(prefix + "/", StringComparison.Ordinal)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
_rowCount = tags.Count;
|
||||
_csv = RawTagCsvMapper.Export(DriverType, tags);
|
||||
_fileName = BuildFileName(deviceId);
|
||||
|
||||
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(_csv));
|
||||
_dataUri = $"data:text/csv;charset=utf-8;base64,{base64}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildFileName(string deviceId)
|
||||
{
|
||||
var safe = new string(deviceId.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
|
||||
return $"tags-{safe}.csv";
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
_built = false;
|
||||
_csv = "";
|
||||
_dataUri = "";
|
||||
Visible = false;
|
||||
await VisibleChanged.InvokeAsync(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
@* Staged CSV tag import for a Raw-tree Device / TagGroup (B2-WP5).
|
||||
|
||||
Flow: upload a file (InputFile) → parse via the T0-2 CsvParser + the per-driver CsvColumnMap →
|
||||
a REVIEW GRID with a per-row verdict (OK / the validation error) and, per row, which columns were
|
||||
typed vs. taken from the TagConfigJson fallback → Commit, which calls IRawTreeService.ImportTagsAsync.
|
||||
ImportTagsAsync is all-or-nothing, so Commit is BLOCKED while any row is invalid — no partial commit.
|
||||
|
||||
Modal contract (the RawTree coordinator wires this in place of the OnImportCsv stub):
|
||||
<RawCsvImportModal @bind-Visible="_importVisible"
|
||||
DeviceId="@_importDeviceId" (may be blank if TagGroupId is supplied)
|
||||
TagGroupId="@_importTagGroupId" (target group; its path is prepended to every row)
|
||||
DriverType="@_importDriverType"
|
||||
OnSaved="ReloadNodeChildren" />
|
||||
For a Device node: DeviceId = node.EntityId, TagGroupId = null.
|
||||
For a TagGroup node: TagGroupId = node.EntityId (DeviceId is resolved from it if not passed). *@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@inject IRawTreeService Svc
|
||||
@inject RawTagCsvExportReader ExportReader
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Import tags — CSV</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (!string.IsNullOrEmpty(_fatal))
|
||||
{
|
||||
<div class="alert alert-danger">@_fatal</div>
|
||||
}
|
||||
|
||||
@if (_result is null)
|
||||
{
|
||||
<p class="text-muted">
|
||||
Upload a CSV whose header row carries the columns for a
|
||||
<span class="mono">@DriverType</span> device.
|
||||
Target: <span class="mono">@TargetLabel</span>.
|
||||
</p>
|
||||
<InputFile OnChange="OnFileSelected" accept=".csv,text/csv" class="form-control" />
|
||||
@if (_parsing)
|
||||
{
|
||||
<p class="text-muted mt-2"><span class="spinner-border spinner-border-sm"></span> Parsing…</p>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div>
|
||||
<span class="badge bg-secondary">@_result.Rows.Count rows</span>
|
||||
<span class="badge bg-success">@OkCount valid</span>
|
||||
@if (ErrorCount > 0)
|
||||
{
|
||||
<span class="badge bg-danger">@ErrorCount invalid</span>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="Reset">Choose another file</button>
|
||||
</div>
|
||||
|
||||
@if (ErrorCount > 0)
|
||||
{
|
||||
<div class="alert alert-warning py-1">
|
||||
Import is all-or-nothing — fix or remove the invalid rows before committing.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="table-responsive" style="max-height:50vh;overflow:auto">
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead class="sticky-top bg-body">
|
||||
<tr>
|
||||
<th style="width:2.5rem"></th>
|
||||
<th>Row</th>
|
||||
<th>Name</th>
|
||||
<th>Group</th>
|
||||
<th>Verdict</th>
|
||||
<th>Provenance</th>
|
||||
<th style="width:2.5rem"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in _result.Rows)
|
||||
{
|
||||
if (_removed.Contains(row.RowNumber)) { continue; }
|
||||
<tr class="@(row.Ok ? "" : "table-danger")">
|
||||
<td>@(row.Ok ? "✓" : "✕")</td>
|
||||
<td class="mono">@row.RowNumber</td>
|
||||
<td class="mono">@row.Name</td>
|
||||
<td class="mono small">@(row.EffectiveGroupPath.Length == 0 ? "(device root)" : row.EffectiveGroupPath)</td>
|
||||
<td>
|
||||
@if (row.Ok)
|
||||
{
|
||||
<span class="text-success">OK</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-danger">@row.Error</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
@if (row.TypedColumnsUsed.Count > 0)
|
||||
{
|
||||
<span class="text-muted">typed: </span>
|
||||
<span class="mono">@string.Join(", ", row.TypedColumnsUsed)</span>
|
||||
}
|
||||
@if (row.FallbackKeys.Count > 0)
|
||||
{
|
||||
<br /><span class="text-muted">TagConfigJson: </span>
|
||||
<span class="mono">@string.Join(", ", row.FallbackKeys)</span>
|
||||
}
|
||||
@if (row.TypedOverrodeFallback.Count > 0)
|
||||
{
|
||||
<br /><span class="badge bg-info text-dark" title="A typed column overrode the same key in TagConfigJson">
|
||||
typed wins: @string.Join(", ", row.TypedOverrodeFallback)
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger py-0"
|
||||
title="Remove this row from the import"
|
||||
@onclick="() => RemoveRow(row.RowNumber)">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (_commitErrors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger mt-2">
|
||||
<strong>Commit rejected:</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var e in _commitErrors)
|
||||
{
|
||||
<li>@e</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary"
|
||||
disabled="@(!CanCommit || _committing)"
|
||||
@onclick="Commit">
|
||||
@if (_committing)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm"></span>
|
||||
}
|
||||
Commit @RemainingCount tag@(RemainingCount == 1 ? "" : "s")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the modal is shown (two-way bound).</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>Raised when <see cref="Visible"/> changes (supports <c>@bind-Visible</c>).</summary>
|
||||
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||
|
||||
/// <summary>The target device's logical id. May be blank when <see cref="TagGroupId"/> is supplied
|
||||
/// (the device is then resolved from the group).</summary>
|
||||
[Parameter] public string DeviceId { get; set; } = "";
|
||||
|
||||
/// <summary>The target tag-group's logical id, or null to import at the device root. Its device-relative
|
||||
/// path is prepended to every imported row's <c>TagGroupPath</c>.</summary>
|
||||
[Parameter] public string? TagGroupId { get; set; }
|
||||
|
||||
/// <summary>The device's driver-type string — selects the per-driver typed column set.</summary>
|
||||
[Parameter] public string DriverType { get; set; } = "";
|
||||
|
||||
/// <summary>Raised after a successful commit so the coordinator can reload the node's children.</summary>
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
|
||||
private RawCsvParseResult? _result;
|
||||
private readonly HashSet<int> _removed = new();
|
||||
private List<string> _commitErrors = new();
|
||||
private string? _fatal;
|
||||
private bool _parsing;
|
||||
private bool _committing;
|
||||
private bool _resolved;
|
||||
|
||||
private string _effectiveDeviceId = "";
|
||||
private string? _groupPrefix;
|
||||
|
||||
private string TargetLabel =>
|
||||
string.IsNullOrEmpty(_groupPrefix) ? _effectiveDeviceId : $"{_effectiveDeviceId} / {_groupPrefix}";
|
||||
|
||||
private int OkCount => _result?.Rows.Count(r => r.Ok && !_removed.Contains(r.RowNumber)) ?? 0;
|
||||
private int ErrorCount => _result?.Rows.Count(r => !r.Ok && !_removed.Contains(r.RowNumber)) ?? 0;
|
||||
private int RemainingCount => _result?.Rows.Count(r => !_removed.Contains(r.RowNumber)) ?? 0;
|
||||
|
||||
// Commit is unblocked only when every remaining row is valid AND at least one row remains.
|
||||
private bool CanCommit =>
|
||||
_result is not null && _fatal is null && RemainingCount > 0 && ErrorCount == 0;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (Visible && !_resolved)
|
||||
{
|
||||
await ResolveTargetAsync();
|
||||
_resolved = true;
|
||||
}
|
||||
else if (!Visible)
|
||||
{
|
||||
_resolved = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the effective device id + the group-path prefix once per open.
|
||||
private async Task ResolveTargetAsync()
|
||||
{
|
||||
_effectiveDeviceId = DeviceId;
|
||||
_groupPrefix = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(TagGroupId))
|
||||
{
|
||||
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
|
||||
if (ctx is { } c)
|
||||
{
|
||||
_groupPrefix = c.GroupPath;
|
||||
if (string.IsNullOrEmpty(_effectiveDeviceId))
|
||||
{
|
||||
_effectiveDeviceId = c.DeviceId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_fatal = null;
|
||||
_commitErrors = new();
|
||||
_removed.Clear();
|
||||
_parsing = true;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 16 * 1024 * 1024);
|
||||
using var reader = new StreamReader(stream);
|
||||
var text = await reader.ReadToEndAsync();
|
||||
_result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix);
|
||||
_fatal = _result.FatalError;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_fatal = $"Could not read the file: {ex.Message}";
|
||||
_result = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_parsing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveRow(int rowNumber) => _removed.Add(rowNumber);
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
_result = null;
|
||||
_fatal = null;
|
||||
_commitErrors = new();
|
||||
_removed.Clear();
|
||||
}
|
||||
|
||||
private async Task Commit()
|
||||
{
|
||||
if (_result is null || !CanCommit)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_committing = true;
|
||||
_commitErrors = new();
|
||||
try
|
||||
{
|
||||
var rows = _result.Rows
|
||||
.Where(r => r is { Ok: true, ImportRow: not null } && !_removed.Contains(r.RowNumber))
|
||||
.Select(r => r.ImportRow!)
|
||||
.ToList();
|
||||
|
||||
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
|
||||
if (outcome.Errors.Count > 0)
|
||||
{
|
||||
_commitErrors = outcome.Errors.ToList();
|
||||
return;
|
||||
}
|
||||
|
||||
await OnSaved.InvokeAsync();
|
||||
await Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_commitErrors = new() { ex.Message };
|
||||
}
|
||||
finally
|
||||
{
|
||||
_committing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
Reset();
|
||||
Visible = false;
|
||||
_resolved = false;
|
||||
await VisibleChanged.InvokeAsync(false);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ public static class EndpointRouteBuilderExtensions
|
||||
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
|
||||
services.AddScoped<IUnsTreeService, UnsTreeService>();
|
||||
services.AddScoped<IRawTreeService, RawTreeService>();
|
||||
// WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
|
||||
services.AddScoped<RawTagCsvExportReader>();
|
||||
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
|
||||
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
|
||||
// Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only enumeration helper for the WP5 CSV export path. Reads a device's tag-groups + tags directly
|
||||
/// from the config DB and flattens them (with each tag's reconstructed <c>/</c>-separated
|
||||
/// <see cref="RawCsvExportTag.TagGroupPath"/>) into the export shape. Owned by WP5 — it does NOT extend
|
||||
/// <see cref="IRawTreeService"/> (that prelude is closed); it takes its own
|
||||
/// <see cref="IDbContextFactory{TContext}"/> so export never mutates. Also resolves a tag-group's path +
|
||||
/// owning device (the import modal's target-group prefix seam).
|
||||
/// </summary>
|
||||
/// <param name="dbFactory">The config-DB context factory.</param>
|
||||
public sealed class RawTagCsvExportReader(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
|
||||
{
|
||||
/// <summary>
|
||||
/// The owning device + reconstructed path for a tag-group (used by the import modal to prepend a
|
||||
/// target-group prefix to every imported row).
|
||||
/// </summary>
|
||||
/// <param name="DeviceId">The group's owning device id.</param>
|
||||
/// <param name="GroupPath">The group's <c>/</c>-separated path under the device (device-relative, no device segment).</param>
|
||||
public readonly record struct TagGroupContext(string DeviceId, string GroupPath);
|
||||
|
||||
/// <summary>Resolves the driver-type string of the device's parent driver, or null when unresolved.</summary>
|
||||
/// <param name="deviceId">The device's logical id.</param>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
/// <returns>The driver-type string, or null.</returns>
|
||||
public async Task<string?> ResolveDriverTypeAsync(string deviceId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
return await (from dev in db.Devices.AsNoTracking()
|
||||
join drv in db.DriverInstances.AsNoTracking() on dev.DriverInstanceId equals drv.DriverInstanceId
|
||||
where dev.DeviceId == deviceId
|
||||
select drv.DriverType).FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a tag-group's owning device + device-relative path. Walks the <c>ParentTagGroupId</c> chain
|
||||
/// to the device root and joins the segment names with <c>/</c>. Returns null when the group is unknown.
|
||||
/// </summary>
|
||||
/// <param name="tagGroupId">The tag-group's logical id.</param>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
/// <returns>The owning device + reconstructed path, or null when the group does not exist.</returns>
|
||||
public async Task<TagGroupContext?> ResolveGroupContextAsync(string tagGroupId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
|
||||
if (group is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var byId = await db.TagGroups.AsNoTracking()
|
||||
.Where(g => g.DeviceId == group.DeviceId)
|
||||
.ToDictionaryAsync(g => g.TagGroupId, g => (g.Name, g.ParentTagGroupId), ct);
|
||||
|
||||
var path = BuildGroupPath(tagGroupId, byId);
|
||||
return new TagGroupContext(group.DeviceId, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates every tag under a device (across all nested tag-groups), each carrying its
|
||||
/// device-relative group path, in a stable order (group path, then name).
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device's logical id.</param>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
/// <returns>The device's tags flattened for export.</returns>
|
||||
public async Task<IReadOnlyList<RawCsvExportTag>> ReadDeviceTagsAsync(string deviceId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var groups = await db.TagGroups.AsNoTracking()
|
||||
.Where(g => g.DeviceId == deviceId)
|
||||
.ToDictionaryAsync(g => g.TagGroupId, g => (g.Name, g.ParentTagGroupId), ct);
|
||||
|
||||
var tags = await db.Tags.AsNoTracking()
|
||||
.Where(t => t.DeviceId == deviceId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return tags
|
||||
.Select(t => new RawCsvExportTag(
|
||||
TagGroupPath: t.TagGroupId is null ? null : NullIfEmpty(BuildGroupPath(t.TagGroupId, groups)),
|
||||
Name: t.Name,
|
||||
DataType: t.DataType,
|
||||
AccessLevel: t.AccessLevel,
|
||||
WriteIdempotent: t.WriteIdempotent,
|
||||
PollGroupId: t.PollGroupId,
|
||||
TagConfig: t.TagConfig))
|
||||
.OrderBy(x => x.TagGroupPath ?? "", RawPaths.Comparer)
|
||||
.ThenBy(x => x.Name, RawPaths.Comparer)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string s) => s.Length == 0 ? null : s;
|
||||
|
||||
// Joins a group's segment names root→leaf into a device-relative path, following ParentTagGroupId.
|
||||
private static string BuildGroupPath(
|
||||
string? groupId,
|
||||
IReadOnlyDictionary<string, (string Name, string? ParentTagGroupId)> byId)
|
||||
{
|
||||
var segments = new Stack<string>();
|
||||
var guard = 0;
|
||||
while (groupId is not null && byId.TryGetValue(groupId, out var g))
|
||||
{
|
||||
segments.Push(g.Name);
|
||||
groupId = g.ParentTagGroupId;
|
||||
if (++guard > 1024)
|
||||
{
|
||||
break; // defensive: never loop forever on a malformed parent cycle
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join(RawPaths.SeparatorString, segments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// The pure WP5 CSV import/export engine for the Raw tree. Bridges the T0-2 <see cref="CsvParser"/> /
|
||||
/// <see cref="CsvWriter"/> and the per-driver <see cref="CsvColumnMap"/> to the service's
|
||||
/// <see cref="RawTagImportRow"/> commit shape: <see cref="Parse"/> turns an uploaded CSV into a
|
||||
/// per-row review model with validation verdicts; <see cref="Export"/> renders a device's tags back to
|
||||
/// the same column shape. No I/O, no DB, no Blazor — unit-testable in isolation.
|
||||
/// <para>
|
||||
/// A per-tag driver <c>TagConfig</c> JSON is assembled in a fixed precedence:
|
||||
/// <c>TagConfigJson</c> (base) ← typed driver columns (override) ← the alarm / historize / array flag
|
||||
/// columns. A typed column therefore <b>wins</b> over a conflicting key carried in the <c>TagConfigJson</c>
|
||||
/// fallback; the review model flags when that override happened so the operator sees it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RawTagCsvMapper
|
||||
{
|
||||
// Root TagConfig keys owned by the flag columns — stripped from the export residual so the
|
||||
// TagConfigJson fallback never duplicates a value already carried by a dedicated column.
|
||||
private static readonly string[] FlagKeys =
|
||||
["isHistorized", "historianTagname", "isArray", "arrayLength", "alarm"];
|
||||
|
||||
// ---------------------------------------------------------------------------------------- Import
|
||||
|
||||
/// <summary>
|
||||
/// Parses an uploaded CSV into a review model: one <see cref="RawCsvReviewRow"/> per data row with a
|
||||
/// validation verdict, the assembled <see cref="RawTagImportRow"/> (when valid), and the typed vs.
|
||||
/// <c>TagConfigJson</c>-fallback provenance. Intra-batch duplicate names (same effective group + name)
|
||||
/// are flagged on the later row. This is a dry parse — nothing is committed.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The target device's driver-type string (selects the typed column map).</param>
|
||||
/// <param name="csvText">The uploaded CSV document.</param>
|
||||
/// <param name="groupPathPrefix">The target tag-group's path prefix (from the modal's TagGroupId), prepended
|
||||
/// to every row's <c>TagGroupPath</c>; null/blank when importing at the device root.</param>
|
||||
/// <returns>The parse result: header list, per-row review rows, and any fatal (whole-file) error.</returns>
|
||||
public static RawCsvParseResult Parse(string driverType, string? csvText, string? groupPathPrefix = null)
|
||||
{
|
||||
IReadOnlyList<string[]> raw;
|
||||
try
|
||||
{
|
||||
raw = CsvParser.Parse(csvText);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return new RawCsvParseResult(Array.Empty<string>(), Array.Empty<RawCsvReviewRow>(), $"Malformed CSV: {ex.Message}");
|
||||
}
|
||||
|
||||
if (raw.Count == 0)
|
||||
{
|
||||
return new RawCsvParseResult(Array.Empty<string>(), Array.Empty<RawCsvReviewRow>(), "The file is empty.");
|
||||
}
|
||||
|
||||
var headers = raw[0];
|
||||
var headerIndex = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < headers.Length; i++)
|
||||
{
|
||||
if (!headerIndex.TryAdd(headers[i], i))
|
||||
{
|
||||
return new RawCsvParseResult(headers, Array.Empty<RawCsvReviewRow>(), $"Duplicate header column '{headers[i]}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!headerIndex.ContainsKey("Name"))
|
||||
{
|
||||
return new RawCsvParseResult(headers, Array.Empty<RawCsvReviewRow>(), "Required column 'Name' is missing from the header row.");
|
||||
}
|
||||
|
||||
var map = CsvColumnMap.Resolve(driverType);
|
||||
var reviewed = new List<RawCsvReviewRow>(raw.Count - 1);
|
||||
|
||||
// Skip a wholly-blank trailing/interstitial line (the RFC parser yields [""] for an empty line).
|
||||
for (var r = 1; r < raw.Count; r++)
|
||||
{
|
||||
var cells = raw[r];
|
||||
if (cells.Length == 1 && cells[0].Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
reviewed.Add(BuildRow(r, headers, headerIndex, cells, map, groupPathPrefix));
|
||||
}
|
||||
|
||||
FlagDuplicates(reviewed);
|
||||
return new RawCsvParseResult(headers, reviewed, null);
|
||||
}
|
||||
|
||||
private static RawCsvReviewRow BuildRow(
|
||||
int rowNumber,
|
||||
string[] headers,
|
||||
IReadOnlyDictionary<string, int> headerIndex,
|
||||
string[] cells,
|
||||
CsvDriverTagMap? map,
|
||||
string? groupPathPrefix)
|
||||
{
|
||||
var row = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (name, idx) in headerIndex)
|
||||
{
|
||||
row[name] = idx < cells.Length ? cells[idx] : "";
|
||||
}
|
||||
|
||||
string Cell(string name) => row.TryGetValue(name, out var v) ? v.Trim() : "";
|
||||
|
||||
var typedUsed = new List<string>();
|
||||
var overrodeFallback = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// --- Common identity/access fields ---
|
||||
var name = Cell("Name");
|
||||
if (RawPaths.ValidateSegment(name) is { } nameErr)
|
||||
{
|
||||
return Invalid(rowNumber, cells, $"Name: {nameErr}");
|
||||
}
|
||||
|
||||
var dataType = Cell("DataType");
|
||||
if (dataType.Length == 0)
|
||||
{
|
||||
return Invalid(rowNumber, cells, "DataType is required.");
|
||||
}
|
||||
|
||||
var accessRaw = Cell("AccessLevel");
|
||||
var access = accessRaw.Length == 0
|
||||
? TagAccessLevel.Read
|
||||
: CsvValues.ParseEnum<TagAccessLevel>("AccessLevel", accessRaw);
|
||||
|
||||
var writeIdemRaw = Cell("WriteIdempotent");
|
||||
var writeIdempotent = writeIdemRaw.Length != 0 && CsvValues.ParseBool("WriteIdempotent", writeIdemRaw);
|
||||
|
||||
var pollGroup = Cell("PollGroup");
|
||||
var pollGroupId = pollGroup.Length == 0 ? null : pollGroup;
|
||||
|
||||
var groupPath = CombineGroupPath(groupPathPrefix, Cell("TagGroupPath"));
|
||||
|
||||
// --- Assemble the TagConfig JSON: base ← typed ← flags ---
|
||||
var baseJson = Cell(CsvColumnMap.TagConfigJsonColumn);
|
||||
var baseKeys = ReadRootKeys(baseJson.Length == 0 ? "{}" : baseJson, out var baseValid);
|
||||
if (!baseValid)
|
||||
{
|
||||
return Invalid(rowNumber, cells, "TagConfigJson is not well-formed JSON.");
|
||||
}
|
||||
|
||||
if (map is not null)
|
||||
{
|
||||
foreach (var col in map.Columns)
|
||||
{
|
||||
if (row.TryGetValue(col.Header, out var cv) && !string.IsNullOrWhiteSpace(cv))
|
||||
{
|
||||
typedUsed.Add(col.Header);
|
||||
if (baseKeys.Contains(col.JsonKey))
|
||||
{
|
||||
overrodeFallback.Add(col.Header);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var json = map?.ApplyTypedColumns(baseJson.Length == 0 ? "{}" : baseJson, row)
|
||||
?? (baseJson.Length == 0 ? "{}" : baseJson);
|
||||
|
||||
// Historize flags.
|
||||
var isHistorized = ParseFlag("IsHistorized", Cell("IsHistorized"));
|
||||
json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname"));
|
||||
|
||||
// Array flags.
|
||||
var isArray = ParseFlag("IsArray", Cell("IsArray"));
|
||||
uint? arrayLength = null;
|
||||
var arrayLenRaw = Cell("ArrayLength");
|
||||
if (arrayLenRaw.Length != 0)
|
||||
{
|
||||
var n = CsvValues.ParseInt("ArrayLength", arrayLenRaw);
|
||||
if (n < 0)
|
||||
{
|
||||
return Invalid(rowNumber, cells, "ArrayLength must not be negative.");
|
||||
}
|
||||
|
||||
arrayLength = (uint)n;
|
||||
}
|
||||
|
||||
if (TagArrayConfig.Validate(isArray, arrayLength) is { } arrErr)
|
||||
{
|
||||
return Invalid(rowNumber, cells, arrErr);
|
||||
}
|
||||
|
||||
json = TagArrayConfig.Set(json, isArray, arrayLength);
|
||||
|
||||
// Alarm flags — an alarm is authored iff Alarm.AlarmType is present.
|
||||
json = ApplyAlarm(json, Cell("Alarm.AlarmType"), Cell("Alarm.Severity"), Cell("Alarm.HistorizeToAveva"));
|
||||
|
||||
var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json));
|
||||
|
||||
return new RawCsvReviewRow
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Cells = cells,
|
||||
Headers = headers,
|
||||
Error = null,
|
||||
ImportRow = importRow,
|
||||
TypedColumnsUsed = typedUsed,
|
||||
FallbackKeys = baseKeys,
|
||||
TypedOverrodeFallback = overrodeFallback,
|
||||
EffectiveGroupPath = groupPath ?? "",
|
||||
Name = name,
|
||||
};
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return Invalid(rowNumber, cells, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static RawCsvReviewRow Invalid(int rowNumber, string[] cells, string error) => new()
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Cells = cells,
|
||||
Headers = Array.Empty<string>(),
|
||||
Error = error,
|
||||
TypedColumnsUsed = Array.Empty<string>(),
|
||||
FallbackKeys = Array.Empty<string>(),
|
||||
TypedOverrodeFallback = Array.Empty<string>(),
|
||||
};
|
||||
|
||||
// Flags a same-(group+name) collision within the parsed batch on the SECOND-and-later row.
|
||||
private static void FlagDuplicates(List<RawCsvReviewRow> rows)
|
||||
{
|
||||
var seen = new HashSet<(string, string)>();
|
||||
foreach (var row in rows.Where(r => r.Ok))
|
||||
{
|
||||
if (!seen.Add((row.EffectiveGroupPath, row.Name)))
|
||||
{
|
||||
var where = row.EffectiveGroupPath.Length == 0 ? "the device root" : $"group '{row.EffectiveGroupPath}'";
|
||||
row.Error = $"Duplicate tag name '{row.Name}' under {where} (already appears earlier in this file).";
|
||||
row.ImportRow = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? CombineGroupPath(string? prefix, string cell)
|
||||
{
|
||||
var suffix = string.IsNullOrWhiteSpace(cell) ? null : cell.Trim();
|
||||
prefix = string.IsNullOrWhiteSpace(prefix) ? null : prefix.Trim();
|
||||
if (prefix is null)
|
||||
{
|
||||
return suffix;
|
||||
}
|
||||
|
||||
return suffix is null ? prefix : $"{prefix}{RawPaths.SeparatorString}{suffix}";
|
||||
}
|
||||
|
||||
private static bool ParseFlag(string header, string cell)
|
||||
=> cell.Length != 0 && CsvValues.ParseBool(header, cell);
|
||||
|
||||
private static string ApplyAlarm(string json, string alarmType, string severityRaw, string histRaw)
|
||||
{
|
||||
if (alarmType.Length == 0)
|
||||
{
|
||||
return json;
|
||||
}
|
||||
|
||||
var model = NativeAlarmModel.FromJson(json);
|
||||
model.IsAlarm = true;
|
||||
model.AlarmType = alarmType;
|
||||
model.Severity = severityRaw.Length == 0 ? model.Severity : CsvValues.ParseInt("Alarm.Severity", severityRaw);
|
||||
model.HistorizeToAveva = histRaw.Length == 0 ? null : CsvValues.ParseBool("Alarm.HistorizeToAveva", histRaw);
|
||||
return model.ToJson();
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> ReadRootKeys(string json, out bool valid)
|
||||
{
|
||||
JsonNode? node;
|
||||
try
|
||||
{
|
||||
node = JsonNode.Parse(json);
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
valid = false;
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
valid = false;
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
valid = true;
|
||||
return obj.Select(kvp => kvp.Key).ToArray();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------- Export
|
||||
|
||||
/// <summary>
|
||||
/// Renders a device's tags to a CSV string in the same column shape <see cref="Parse"/> consumes:
|
||||
/// the common columns, the driver's typed columns, then the trailing <c>TagConfigJson</c> fallback
|
||||
/// carrying only the residual keys (those not surfaced by a typed or flag column) so a round-trip is
|
||||
/// exact and non-redundant.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The device's driver-type string (selects the typed column set).</param>
|
||||
/// <param name="tags">The tags to export.</param>
|
||||
/// <returns>The CSV document (CRLF, quote-on-demand).</returns>
|
||||
public static string Export(string driverType, IEnumerable<RawCsvExportTag> tags)
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(driverType);
|
||||
var typedHeaders = map?.Headers ?? Array.Empty<string>();
|
||||
var header = CsvColumnMap.HeadersFor(driverType);
|
||||
|
||||
var rows = new List<string[]> { header.ToArray() };
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
rows.Add(BuildExportRow(tag, map, typedHeaders));
|
||||
}
|
||||
|
||||
return CsvWriter.WriteToString(rows);
|
||||
}
|
||||
|
||||
private static string[] BuildExportRow(RawCsvExportTag tag, CsvDriverTagMap? map, IReadOnlyList<string> typedHeaders)
|
||||
{
|
||||
var json = string.IsNullOrWhiteSpace(tag.TagConfig) ? "{}" : tag.TagConfig;
|
||||
|
||||
var hist = TagHistorizeConfig.Read(json);
|
||||
var array = TagArrayConfig.Read(json);
|
||||
var alarm = NativeAlarmModel.FromJson(json);
|
||||
var typed = map?.ReadTypedColumns(json) ?? new Dictionary<string, string>();
|
||||
|
||||
var cells = new List<string>(CsvColumnMap.CommonLeadingColumns.Count + typedHeaders.Count + 1)
|
||||
{
|
||||
tag.Name,
|
||||
tag.TagGroupPath ?? "",
|
||||
tag.DataType,
|
||||
tag.AccessLevel.ToString(),
|
||||
tag.WriteIdempotent ? "true" : "false",
|
||||
tag.PollGroupId ?? "",
|
||||
hist.IsHistorized ? "true" : "",
|
||||
hist.HistorianTagname,
|
||||
array.IsArray ? "true" : "",
|
||||
array.ArrayLength?.ToString(CultureInfo.InvariantCulture) ?? "",
|
||||
alarm.IsAlarm ? alarm.AlarmType : "",
|
||||
alarm.IsAlarm ? alarm.Severity.ToString(CultureInfo.InvariantCulture) : "",
|
||||
alarm.IsAlarm ? (CsvValues.FormatNullableBool(alarm.HistorizeToAveva) ?? "") : "",
|
||||
};
|
||||
|
||||
foreach (var h in typedHeaders)
|
||||
{
|
||||
cells.Add(typed.TryGetValue(h, out var v) ? v : "");
|
||||
}
|
||||
|
||||
cells.Add(ComputeResidual(json, map));
|
||||
return cells.ToArray();
|
||||
}
|
||||
|
||||
// The TagConfigJson fallback cell: the tag's root keys minus every key a typed or flag column already
|
||||
// surfaces. Empty object ⇒ blank cell.
|
||||
private static string ComputeResidual(string json, CsvDriverTagMap? map)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
foreach (var key in map?.JsonKeys ?? Array.Empty<string>())
|
||||
{
|
||||
o.Remove(key);
|
||||
}
|
||||
|
||||
foreach (var key in FlagKeys)
|
||||
{
|
||||
o.Remove(key);
|
||||
}
|
||||
|
||||
return o.Count == 0 ? "" : TagConfigJson.Serialize(o);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One tag flattened for CSV export: its effective tag-group path under the device plus the operator-facing
|
||||
/// fields. Produced by <see cref="RawTagCsvExportReader"/> and consumed by <see cref="RawTagCsvMapper.Export"/>.
|
||||
/// </summary>
|
||||
/// <param name="TagGroupPath">The <c>/</c>-separated group path under the device, or null for a device-root tag.</param>
|
||||
/// <param name="Name">The tag name.</param>
|
||||
/// <param name="DataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="AccessLevel">The tag's access-level baseline.</param>
|
||||
/// <param name="WriteIdempotent">Whether writes are retry-eligible.</param>
|
||||
/// <param name="PollGroupId">The optional poll-group id.</param>
|
||||
/// <param name="TagConfig">The raw per-driver TagConfig JSON blob.</param>
|
||||
public sealed record RawCsvExportTag(
|
||||
string? TagGroupPath,
|
||||
string Name,
|
||||
string DataType,
|
||||
TagAccessLevel AccessLevel,
|
||||
bool WriteIdempotent,
|
||||
string? PollGroupId,
|
||||
string TagConfig);
|
||||
|
||||
/// <summary>
|
||||
/// One reviewed CSV data row: its 1-based row number, raw cells, validation verdict, and — when valid —
|
||||
/// the assembled <see cref="RawTagImportRow"/> plus the typed vs. <c>TagConfigJson</c>-fallback provenance
|
||||
/// the review grid renders.
|
||||
/// </summary>
|
||||
public sealed class RawCsvReviewRow
|
||||
{
|
||||
/// <summary>The 1-based data-row number in the source file (header is row 0).</summary>
|
||||
public int RowNumber { get; init; }
|
||||
|
||||
/// <summary>The raw cell values as parsed.</summary>
|
||||
public IReadOnlyList<string> Cells { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>The header row (for aligning <see cref="Cells"/> in the grid).</summary>
|
||||
public IReadOnlyList<string> Headers { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>The validation error, or <c>null</c> when the row is valid.</summary>
|
||||
public string? Error { get; set; }
|
||||
|
||||
/// <summary>Whether the row is valid (commit-eligible).</summary>
|
||||
public bool Ok => Error is null;
|
||||
|
||||
/// <summary>The assembled import row when valid; <c>null</c> otherwise.</summary>
|
||||
public RawTagImportRow? ImportRow { get; set; }
|
||||
|
||||
/// <summary>The typed-column headers that supplied a value on this row.</summary>
|
||||
public IReadOnlyList<string> TypedColumnsUsed { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>The root keys present in the row's <c>TagConfigJson</c> fallback.</summary>
|
||||
public IReadOnlyCollection<string> FallbackKeys { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>The typed columns that overrode a conflicting key carried in <c>TagConfigJson</c>.</summary>
|
||||
public IReadOnlyList<string> TypedOverrodeFallback { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>The effective tag-group path (prefix + row's TagGroupPath) used for duplicate detection.</summary>
|
||||
public string EffectiveGroupPath { get; init; } = "";
|
||||
|
||||
/// <summary>The tag name (echoed for duplicate detection + grid display).</summary>
|
||||
public string Name { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of <see cref="RawTagCsvMapper.Parse"/>: the parsed header row, the per-row review model, and
|
||||
/// a fatal (whole-file) error when the document could not be parsed at all (malformed CSV, duplicate/absent
|
||||
/// required header). When <see cref="FatalError"/> is set, <see cref="Rows"/> is empty.
|
||||
/// </summary>
|
||||
/// <param name="Headers">The parsed header row.</param>
|
||||
/// <param name="Rows">The per-row review model.</param>
|
||||
/// <param name="FatalError">A whole-file error, or null when the file parsed.</param>
|
||||
public sealed record RawCsvParseResult(
|
||||
IReadOnlyList<string> Headers,
|
||||
IReadOnlyList<RawCsvReviewRow> Rows,
|
||||
string? FatalError)
|
||||
{
|
||||
/// <summary>Whether the file parsed and every row is valid (commit is unblocked).</summary>
|
||||
public bool CanCommit => FatalError is null && Rows.Count > 0 && Rows.All(r => r.Ok);
|
||||
|
||||
/// <summary>The valid rows' assembled import rows, ready for <c>ImportTagsAsync</c>.</summary>
|
||||
public IReadOnlyList<RawTagImportRow> ToImportRows()
|
||||
=> Rows.Where(r => r is { Ok: true, ImportRow: not null }).Select(r => r.ImportRow!).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for one per-driver <b>typed</b> CSV column: its CSV header, the root TagConfig JSON key it
|
||||
/// maps to, and — for the model-backed maps — the name of the property on the driver's
|
||||
/// <c><![CDATA[<Driver>TagConfigModel]]></c> it corresponds to. <see cref="ModelProperty"/> is the anchor
|
||||
/// the CSV column-map reflection guard test asserts against so a driver-model rename can never silently
|
||||
/// drift the CSV surface. It is <c>null</c> for the model-less maps (Galaxy / Calculation), which map
|
||||
/// a header directly onto a JSON key.
|
||||
/// </summary>
|
||||
/// <param name="Header">The CSV column header (PascalCase, e.g. <c>ModbusDataType</c>).</param>
|
||||
/// <param name="JsonKey">The root TagConfig JSON key this column reads/writes (camelCase, e.g. <c>dataType</c>).</param>
|
||||
/// <param name="ModelProperty">The backing model property name, or <c>null</c> for a model-less map.</param>
|
||||
public sealed record CsvTypedColumn(string Header, string JsonKey, string? ModelProperty);
|
||||
|
||||
/// <summary>
|
||||
/// Per-driver mapping between a CSV row's <b>typed</b> columns and a tag's driver-specific
|
||||
/// <c>TagConfig</c> JSON. The map is the inverse pair the WP5 CSV import/export path pivots on:
|
||||
/// <see cref="ApplyTypedColumns"/> folds a row's typed columns onto a base TagConfig (typed column wins
|
||||
/// on conflict); <see cref="ReadTypedColumns"/> projects a stored TagConfig back to its typed cells for
|
||||
/// export. Lives next to the <c><![CDATA[<Driver>TagConfigModel]]></c> types so model + map evolve
|
||||
/// together.
|
||||
/// </summary>
|
||||
public abstract class CsvDriverTagMap
|
||||
{
|
||||
/// <summary>The driver-type string this map serves (a <see cref="DriverTypeNames"/> value).</summary>
|
||||
public abstract string DriverType { get; }
|
||||
|
||||
/// <summary>The backing model type whose scalar surface this map mirrors, or <c>null</c> for a
|
||||
/// model-less (raw-JSON-key) map (Galaxy / Calculation).</summary>
|
||||
public abstract Type? ModelType { get; }
|
||||
|
||||
/// <summary>The ordered typed columns this driver adds to the common CSV column set.</summary>
|
||||
public abstract IReadOnlyList<CsvTypedColumn> Columns { get; }
|
||||
|
||||
/// <summary>The typed column headers, in order.</summary>
|
||||
public IReadOnlyList<string> Headers => Columns.Select(c => c.Header).ToArray();
|
||||
|
||||
/// <summary>The root TagConfig JSON keys this map owns (used to compute the export residual).</summary>
|
||||
public IReadOnlyList<string> JsonKeys => Columns.Select(c => c.JsonKey).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Folds the typed columns present (non-blank) in <paramref name="row"/> onto
|
||||
/// <paramref name="baseJson"/> and returns the merged driver TagConfig JSON. The base is loaded first
|
||||
/// (so its unrecognised keys survive) and each present typed column then overrides its key — so a
|
||||
/// typed column <b>wins</b> over a conflicting key carried in the <c>TagConfigJson</c> base.
|
||||
/// </summary>
|
||||
/// <param name="baseJson">The base TagConfig JSON (from the row's <c>TagConfigJson</c> column), or null/blank.</param>
|
||||
/// <param name="row">The CSV row as a header→value map.</param>
|
||||
/// <returns>The merged TagConfig JSON string.</returns>
|
||||
/// <exception cref="FormatException">A typed cell is not a valid value for its column (bad enum / number / bool).</exception>
|
||||
public abstract string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary<string, string> row);
|
||||
|
||||
/// <summary>
|
||||
/// Projects the typed columns out of a stored TagConfig JSON for export — the inverse of
|
||||
/// <see cref="ApplyTypedColumns"/>. A column absent from the JSON is omitted from the result.
|
||||
/// </summary>
|
||||
/// <param name="tagConfigJson">The tag's stored TagConfig JSON, or null/blank.</param>
|
||||
/// <returns>The typed columns present, keyed by CSV header.</returns>
|
||||
public abstract IReadOnlyDictionary<string, string> ReadTypedColumns(string? tagConfigJson);
|
||||
}
|
||||
|
||||
/// <summary>One typed column of a model-backed map: its metadata plus the read/write pair over the
|
||||
/// concrete <typeparamref name="TModel"/> working model.</summary>
|
||||
/// <typeparam name="TModel">The driver's TagConfig working-model type.</typeparam>
|
||||
/// <param name="Meta">The column metadata (header / JSON key / model property).</param>
|
||||
/// <param name="Get">Projects the model's value to a cell string, or <c>null</c> to omit the column on export.</param>
|
||||
/// <param name="Set">Parses a non-blank cell onto the model (throws <see cref="FormatException"/> on a bad value).</param>
|
||||
public sealed record CsvModelColumn<TModel>(
|
||||
CsvTypedColumn Meta,
|
||||
Func<TModel, string?> Get,
|
||||
Action<TModel, string> Set);
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="CsvDriverTagMap"/> driven by a driver's typed working model. Import loads the base JSON
|
||||
/// through the model's <c>FromJson</c> (preserving unknown keys), applies each present typed cell via its
|
||||
/// <see cref="CsvModelColumn{TModel}.Set"/>, and serialises back through the model's <c>ToJson</c> — so
|
||||
/// the CSV path reuses the exact same preserve-unknown / typed-wins merge the TagModal editor uses.
|
||||
/// </summary>
|
||||
/// <typeparam name="TModel">The driver's TagConfig working-model type.</typeparam>
|
||||
public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
|
||||
{
|
||||
private readonly Func<string?, TModel> _fromJson;
|
||||
private readonly Func<TModel, string> _toJson;
|
||||
private readonly IReadOnlyList<CsvModelColumn<TModel>> _columns;
|
||||
|
||||
/// <summary>Builds a model-backed map.</summary>
|
||||
/// <param name="driverType">The driver-type string served.</param>
|
||||
/// <param name="fromJson">The model's <c>FromJson</c> loader (preserves unknown keys).</param>
|
||||
/// <param name="toJson">The model's <c>ToJson</c> serialiser.</param>
|
||||
/// <param name="columns">The typed columns (metadata + read/write pairs).</param>
|
||||
public ModelBackedCsvDriverTagMap(
|
||||
string driverType,
|
||||
Func<string?, TModel> fromJson,
|
||||
Func<TModel, string> toJson,
|
||||
IReadOnlyList<CsvModelColumn<TModel>> columns)
|
||||
{
|
||||
DriverType = driverType;
|
||||
_fromJson = fromJson;
|
||||
_toJson = toJson;
|
||||
_columns = columns;
|
||||
Columns = columns.Select(c => c.Meta).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string DriverType { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Type? ModelType => typeof(TModel);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<CsvTypedColumn> Columns { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary<string, string> row)
|
||||
{
|
||||
var model = _fromJson(baseJson);
|
||||
foreach (var col in _columns)
|
||||
{
|
||||
if (row.TryGetValue(col.Meta.Header, out var raw) && !string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
col.Set(model, raw.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return _toJson(model);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyDictionary<string, string> ReadTypedColumns(string? tagConfigJson)
|
||||
{
|
||||
var model = _fromJson(tagConfigJson);
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var col in _columns)
|
||||
{
|
||||
if (col.Get(model) is { } v)
|
||||
{
|
||||
result[col.Meta.Header] = v;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The value shape of a raw-key (model-less) CSV column.</summary>
|
||||
public enum CsvRawKeyKind
|
||||
{
|
||||
/// <summary>A JSON string value.</summary>
|
||||
String,
|
||||
|
||||
/// <summary>A JSON boolean value (parsed case-insensitively from <c>true</c>/<c>false</c>).</summary>
|
||||
Bool,
|
||||
|
||||
/// <summary>A JSON integer value.</summary>
|
||||
Int,
|
||||
}
|
||||
|
||||
/// <summary>Metadata + value-kind for one column of a raw-key map.</summary>
|
||||
/// <param name="Meta">The column metadata (header / JSON key; model property is null).</param>
|
||||
/// <param name="Kind">The JSON value kind the header maps onto.</param>
|
||||
public sealed record CsvRawKeyColumn(CsvTypedColumn Meta, CsvRawKeyKind Kind);
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="CsvDriverTagMap"/> for drivers with no typed working model (Galaxy, Calculation): each
|
||||
/// column maps a CSV header directly onto a root TagConfig JSON key, preserving every other key across
|
||||
/// the round-trip.
|
||||
/// </summary>
|
||||
public sealed class RawKeyCsvDriverTagMap : CsvDriverTagMap
|
||||
{
|
||||
private readonly IReadOnlyList<CsvRawKeyColumn> _columns;
|
||||
|
||||
/// <summary>Builds a raw-key map.</summary>
|
||||
/// <param name="driverType">The driver-type string served.</param>
|
||||
/// <param name="columns">The header→JSON-key columns with their value kinds.</param>
|
||||
public RawKeyCsvDriverTagMap(string driverType, IReadOnlyList<CsvRawKeyColumn> columns)
|
||||
{
|
||||
DriverType = driverType;
|
||||
_columns = columns;
|
||||
Columns = columns.Select(c => c.Meta).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string DriverType { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Type? ModelType => null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<CsvTypedColumn> Columns { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary<string, string> row)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(baseJson);
|
||||
foreach (var col in _columns)
|
||||
{
|
||||
if (row.TryGetValue(col.Meta.Header, out var raw) && !string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
var trimmed = raw.Trim();
|
||||
o[col.Meta.JsonKey] = col.Kind switch
|
||||
{
|
||||
CsvRawKeyKind.Bool => JsonValue.Create(CsvValues.ParseBool(col.Meta.Header, trimmed)),
|
||||
CsvRawKeyKind.Int => JsonValue.Create(CsvValues.ParseInt(col.Meta.Header, trimmed)),
|
||||
_ => JsonValue.Create(trimmed),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return TagConfigJson.Serialize(o);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyDictionary<string, string> ReadTypedColumns(string? tagConfigJson)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(tagConfigJson);
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var col in _columns)
|
||||
{
|
||||
if (o.TryGetPropertyValue(col.Meta.JsonKey, out var node) && node is JsonValue v)
|
||||
{
|
||||
result[col.Meta.Header] = col.Kind switch
|
||||
{
|
||||
CsvRawKeyKind.Bool => v.TryGetValue<bool>(out var b) ? (b ? "true" : "false") : v.ToString(),
|
||||
CsvRawKeyKind.Int => v.TryGetValue<long>(out var l)
|
||||
? l.ToString(CultureInfo.InvariantCulture)
|
||||
: v.ToString(),
|
||||
_ => v.TryGetValue<string>(out var s) ? s : v.ToString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Case-insensitive value parsers/formatters for typed CSV cells, throwing a uniform
|
||||
/// <see cref="FormatException"/> (naming the column + the offending value) the review grid surfaces.</summary>
|
||||
public static class CsvValues
|
||||
{
|
||||
/// <summary>Parses an enum member name case-insensitively; throws with the legal members listed.</summary>
|
||||
/// <typeparam name="TEnum">The enum type.</typeparam>
|
||||
/// <param name="header">The CSV column header (for the error message).</param>
|
||||
/// <param name="value">The cell value.</param>
|
||||
/// <returns>The parsed enum value.</returns>
|
||||
/// <exception cref="FormatException">The value is not a member of <typeparamref name="TEnum"/>.</exception>
|
||||
public static TEnum ParseEnum<TEnum>(string header, string value) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(value, ignoreCase: true, out var v) && Enum.IsDefined(v)
|
||||
? v
|
||||
: throw new FormatException(
|
||||
$"Column '{header}': '{value}' is not a valid {typeof(TEnum).Name} " +
|
||||
$"(expected one of: {string.Join(", ", Enum.GetNames<TEnum>())}).");
|
||||
|
||||
/// <summary>Parses an integer (invariant); throws a column-named <see cref="FormatException"/>.</summary>
|
||||
/// <param name="header">The CSV column header (for the error message).</param>
|
||||
/// <param name="value">The cell value.</param>
|
||||
/// <returns>The parsed integer.</returns>
|
||||
/// <exception cref="FormatException">The value is not an integer.</exception>
|
||||
public static int ParseInt(string header, string value)
|
||||
=> int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i)
|
||||
? i
|
||||
: throw new FormatException($"Column '{header}': '{value}' is not a whole number.");
|
||||
|
||||
/// <summary>Parses a boolean case-insensitively (<c>true</c>/<c>false</c>); throws a column-named error.</summary>
|
||||
/// <param name="header">The CSV column header (for the error message).</param>
|
||||
/// <param name="value">The cell value.</param>
|
||||
/// <returns>The parsed boolean.</returns>
|
||||
/// <exception cref="FormatException">The value is not a boolean.</exception>
|
||||
public static bool ParseBool(string header, string value)
|
||||
=> bool.TryParse(value, out var b)
|
||||
? b
|
||||
: throw new FormatException($"Column '{header}': '{value}' is not a boolean (expected true or false).");
|
||||
|
||||
/// <summary>Formats a nullable boolean for export: <c>null</c> ⇒ omitted (null cell), else lowercased.</summary>
|
||||
/// <param name="value">The nullable boolean.</param>
|
||||
/// <returns>The cell string, or <c>null</c> to omit the column.</returns>
|
||||
public static string? FormatNullableBool(bool? value) => value is { } b ? (b ? "true" : "false") : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry of the WP5 CSV column dictionary: the common columns every device's export/import carries,
|
||||
/// plus the per-driver typed <see cref="CsvDriverTagMap"/> resolved by driver type. The single authority
|
||||
/// for "which columns does a device of driver X have".
|
||||
/// </summary>
|
||||
public static class CsvColumnMap
|
||||
{
|
||||
/// <summary>The <c>TagConfigJson</c> fallback column header — the base under which typed columns merge.</summary>
|
||||
public const string TagConfigJsonColumn = "TagConfigJson";
|
||||
|
||||
/// <summary>The common column headers that precede any typed driver columns (in order).</summary>
|
||||
public static IReadOnlyList<string> CommonLeadingColumns { get; } = new[]
|
||||
{
|
||||
"Name",
|
||||
"TagGroupPath",
|
||||
"DataType",
|
||||
"AccessLevel",
|
||||
"WriteIdempotent",
|
||||
"PollGroup",
|
||||
"IsHistorized",
|
||||
"HistorianTagname",
|
||||
"IsArray",
|
||||
"ArrayLength",
|
||||
"Alarm.AlarmType",
|
||||
"Alarm.Severity",
|
||||
"Alarm.HistorizeToAveva",
|
||||
};
|
||||
|
||||
/// <summary>All common columns (leading columns + the trailing <c>TagConfigJson</c> fallback).</summary>
|
||||
public static IReadOnlyList<string> CommonColumns { get; } =
|
||||
[.. CommonLeadingColumns, TagConfigJsonColumn];
|
||||
|
||||
/// <summary>The Calculation (virtual-tag) driver-type string. Not yet a <see cref="DriverTypeNames"/>
|
||||
/// constant (its factory lands in a later Batch-2 package), so it is pinned here for the CSV surface.</summary>
|
||||
public const string CalculationDriverType = "Calculation";
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, CsvDriverTagMap> Maps = BuildMaps();
|
||||
|
||||
/// <summary>All registered driver maps.</summary>
|
||||
public static IReadOnlyCollection<CsvDriverTagMap> All => (IReadOnlyCollection<CsvDriverTagMap>)Maps.Values;
|
||||
|
||||
/// <summary>Resolves the typed map for a driver type, or <c>null</c> when the driver has no typed
|
||||
/// columns (only the common set applies).</summary>
|
||||
/// <param name="driverType">The driver-type string.</param>
|
||||
/// <returns>The map, or <c>null</c>.</returns>
|
||||
public static CsvDriverTagMap? Resolve(string? driverType)
|
||||
=> driverType is not null && Maps.TryGetValue(driverType, out var m) ? m : null;
|
||||
|
||||
/// <summary>The full ordered header row for a device of the given driver type: the leading common
|
||||
/// columns, then the driver's typed columns, then the trailing <c>TagConfigJson</c> fallback.</summary>
|
||||
/// <param name="driverType">The device's driver-type string.</param>
|
||||
/// <returns>The ordered CSV header names.</returns>
|
||||
public static IReadOnlyList<string> HeadersFor(string? driverType)
|
||||
{
|
||||
var typed = Resolve(driverType)?.Headers ?? Array.Empty<string>();
|
||||
return [.. CommonLeadingColumns, .. typed, TagConfigJsonColumn];
|
||||
}
|
||||
|
||||
private static Dictionary<string, CsvDriverTagMap> BuildMaps()
|
||||
{
|
||||
var maps = new CsvDriverTagMap[]
|
||||
{
|
||||
new ModelBackedCsvDriverTagMap<ModbusTagConfigModel>(
|
||||
DriverTypeNames.Modbus, ModbusTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
ModbusCol("Region", "region", nameof(ModbusTagConfigModel.Region),
|
||||
m => m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
|
||||
ModbusCol("Address", "address", nameof(ModbusTagConfigModel.Address),
|
||||
m => m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)),
|
||||
ModbusCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
|
||||
ModbusCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder),
|
||||
m => m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
|
||||
ModbusCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex),
|
||||
m => m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)),
|
||||
ModbusCol("StringLength", "stringLength", nameof(ModbusTagConfigModel.StringLength),
|
||||
m => m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)),
|
||||
ModbusCol("Writable", "writable", nameof(ModbusTagConfigModel.Writable),
|
||||
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<S7TagConfigModel>(
|
||||
DriverTypeNames.S7, S7TagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
S7Col("Address", "address", nameof(S7TagConfigModel.Address),
|
||||
m => m.Address, (m, s) => m.Address = s),
|
||||
S7Col("S7DataType", "dataType", nameof(S7TagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<S7DataType>("S7DataType", s)),
|
||||
S7Col("StringLength", "stringLength", nameof(S7TagConfigModel.StringLength),
|
||||
m => m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)),
|
||||
S7Col("Writable", "writable", nameof(S7TagConfigModel.Writable),
|
||||
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<AbCipTagConfigModel>(
|
||||
DriverTypeNames.AbCip, AbCipTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
AbCipCol("TagPath", "tagPath", nameof(AbCipTagConfigModel.TagPath),
|
||||
m => m.TagPath, (m, s) => m.TagPath = s),
|
||||
AbCipCol("AbCipDataType", "dataType", nameof(AbCipTagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<AbCipDataType>("AbCipDataType", s)),
|
||||
AbCipCol("Writable", "writable", nameof(AbCipTagConfigModel.Writable),
|
||||
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<AbLegacyTagConfigModel>(
|
||||
DriverTypeNames.AbLegacy, AbLegacyTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
AbLegacyCol("Address", "address", nameof(AbLegacyTagConfigModel.Address),
|
||||
m => m.Address, (m, s) => m.Address = s),
|
||||
AbLegacyCol("AbLegacyDataType", "dataType", nameof(AbLegacyTagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<AbLegacyDataType>("AbLegacyDataType", s)),
|
||||
AbLegacyCol("Writable", "writable", nameof(AbLegacyTagConfigModel.Writable),
|
||||
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<TwinCATTagConfigModel>(
|
||||
DriverTypeNames.TwinCAT, TwinCATTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
TwinCatCol("SymbolPath", "symbolPath", nameof(TwinCATTagConfigModel.SymbolPath),
|
||||
m => m.SymbolPath, (m, s) => m.SymbolPath = s),
|
||||
TwinCatCol("TwinCATDataType", "dataType", nameof(TwinCATTagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<TwinCATDataType>("TwinCATDataType", s)),
|
||||
TwinCatCol("Writable", "writable", nameof(TwinCATTagConfigModel.Writable),
|
||||
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<FocasTagConfigModel>(
|
||||
DriverTypeNames.FOCAS, FocasTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
FocasCol("Address", "address", nameof(FocasTagConfigModel.Address),
|
||||
m => m.Address, (m, s) => m.Address = s),
|
||||
FocasCol("FocasDataType", "dataType", nameof(FocasTagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<FocasDataType>("FocasDataType", s)),
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<OpcUaClientTagConfigModel>(
|
||||
DriverTypeNames.OpcUaClient, OpcUaClientTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
OpcUaCol("NodeId", "nodeId", nameof(OpcUaClientTagConfigModel.NodeId),
|
||||
m => m.NodeId, (m, s) => m.NodeId = s),
|
||||
}),
|
||||
|
||||
new RawKeyCsvDriverTagMap(DriverTypeNames.Galaxy, new[]
|
||||
{
|
||||
// Galaxy's driver reference is the PascalCase FullName key (tag_name.AttributeName).
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("AttributeRef", "FullName", null), CsvRawKeyKind.String),
|
||||
}),
|
||||
|
||||
new RawKeyCsvDriverTagMap(CalculationDriverType, new[]
|
||||
{
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("ScriptId", "scriptId", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("ChangeTriggered", "changeTriggered", null), CsvRawKeyKind.Bool),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("TimerIntervalMs", "timerIntervalMs", null), CsvRawKeyKind.Int),
|
||||
}),
|
||||
};
|
||||
|
||||
return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Per-driver column factories — one per model so the Get/Set lambdas stay strongly typed.
|
||||
private static CsvModelColumn<ModbusTagConfigModel> ModbusCol(
|
||||
string h, string k, string p, Func<ModbusTagConfigModel, string?> get, Action<ModbusTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<S7TagConfigModel> S7Col(
|
||||
string h, string k, string p, Func<S7TagConfigModel, string?> get, Action<S7TagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<AbCipTagConfigModel> AbCipCol(
|
||||
string h, string k, string p, Func<AbCipTagConfigModel, string?> get, Action<AbCipTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<AbLegacyTagConfigModel> AbLegacyCol(
|
||||
string h, string k, string p, Func<AbLegacyTagConfigModel, string?> get, Action<AbLegacyTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<TwinCATTagConfigModel> TwinCatCol(
|
||||
string h, string k, string p, Func<TwinCATTagConfigModel, string?> get, Action<TwinCATTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<FocasTagConfigModel> FocasCol(
|
||||
string h, string k, string p, Func<FocasTagConfigModel, string?> get, Action<FocasTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<OpcUaClientTagConfigModel> OpcUaCol(
|
||||
string h, string k, string p, Func<OpcUaClientTagConfigModel, string?> get, Action<OpcUaClientTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Drift guard for the WP5 CSV column dictionary (<see cref="CsvColumnMap"/>): every typed column of a
|
||||
/// model-backed driver map must name a real property on that driver's <c><![CDATA[<Driver>TagConfigModel]]></c>,
|
||||
/// and the registered typed-column set per driver must match the batch-plan column dictionary exactly. A
|
||||
/// model rename or a column-dictionary change breaks this test rather than silently corrupting import/export.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CsvColumnMapReflectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Every_model_backed_typed_column_maps_to_a_real_model_property()
|
||||
{
|
||||
foreach (var map in CsvColumnMap.All.Where(m => m.ModelType is not null))
|
||||
{
|
||||
foreach (var col in map.Columns)
|
||||
{
|
||||
col.ModelProperty.ShouldNotBeNull(
|
||||
$"{map.DriverType} column '{col.Header}' is model-backed but declares no ModelProperty.");
|
||||
|
||||
map.ModelType!.GetProperty(col.ModelProperty!)
|
||||
.ShouldNotBeNull(
|
||||
$"{map.DriverType} column '{col.Header}' maps to '{col.ModelProperty}', " +
|
||||
$"which is not a property on {map.ModelType!.Name}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Model_less_maps_declare_no_model_property()
|
||||
{
|
||||
foreach (var map in CsvColumnMap.All.Where(m => m.ModelType is null))
|
||||
{
|
||||
foreach (var col in map.Columns)
|
||||
{
|
||||
col.ModelProperty.ShouldBeNull(
|
||||
$"{map.DriverType} column '{col.Header}' is model-less but declares ModelProperty '{col.ModelProperty}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.Modbus, "Region", "Address", "ModbusDataType", "ByteOrder", "BitIndex", "StringLength", "Writable")]
|
||||
[InlineData(DriverTypeNames.S7, "Address", "S7DataType", "StringLength", "Writable")]
|
||||
[InlineData(DriverTypeNames.AbCip, "TagPath", "AbCipDataType", "Writable")]
|
||||
[InlineData(DriverTypeNames.AbLegacy, "Address", "AbLegacyDataType", "Writable")]
|
||||
[InlineData(DriverTypeNames.TwinCAT, "SymbolPath", "TwinCATDataType", "Writable")]
|
||||
[InlineData(DriverTypeNames.FOCAS, "Address", "FocasDataType")]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, "NodeId")]
|
||||
[InlineData(DriverTypeNames.Galaxy, "AttributeRef")]
|
||||
[InlineData(CsvColumnMap.CalculationDriverType, "ScriptId", "ChangeTriggered", "TimerIntervalMs")]
|
||||
public void Registered_typed_columns_match_the_batch_plan_dictionary(string driverType, params string[] expected)
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(driverType);
|
||||
map.ShouldNotBeNull($"No CSV map registered for driver '{driverType}'.");
|
||||
map!.Headers.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadersFor_puts_typed_columns_between_common_leading_and_the_TagConfigJson_fallback()
|
||||
{
|
||||
var headers = CsvColumnMap.HeadersFor(DriverTypeNames.Modbus);
|
||||
|
||||
// Leading common columns come first, in order.
|
||||
headers.Take(CsvColumnMap.CommonLeadingColumns.Count).ShouldBe(CsvColumnMap.CommonLeadingColumns);
|
||||
// The typed Modbus columns come next.
|
||||
headers.Skip(CsvColumnMap.CommonLeadingColumns.Count).Take(7)
|
||||
.ShouldBe(CsvColumnMap.Resolve(DriverTypeNames.Modbus)!.Headers);
|
||||
// TagConfigJson is always the trailing fallback.
|
||||
headers[^1].ShouldBe(CsvColumnMap.TagConfigJsonColumn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_driver_headers_are_the_common_set_only()
|
||||
{
|
||||
CsvColumnMap.Resolve("NoSuchDriver").ShouldBeNull();
|
||||
CsvColumnMap.HeadersFor("NoSuchDriver").ShouldBe(CsvColumnMap.CommonColumns);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Property test for the WP5 CSV export→import round-trip: <see cref="RawTagCsvMapper.Export"/> a device's
|
||||
/// tags then re-parse the produced CSV with <see cref="RawTagCsvMapper.Parse"/> and assert the reconstructed
|
||||
/// tag set is identical (name / group path / data type / access / write flag / poll group and a semantically
|
||||
/// equal <c>TagConfig</c>). Exercises typed driver columns, the historize/array/alarm flag columns, the
|
||||
/// residual <c>TagConfigJson</c> fallback (typed-wins), and the model-less (Galaxy) raw-key map.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RawTagCsvRoundTripTests
|
||||
{
|
||||
[Fact]
|
||||
public void Modbus_device_round_trips_typed_columns_flags_and_residual()
|
||||
{
|
||||
// A rich Modbus config: every typed column + historize + array + alarm + a residual ("scale")
|
||||
// key the TagConfigJson fallback must carry.
|
||||
var richTag = new RawCsvExportTag(
|
||||
TagGroupPath: "Bank1/Analog",
|
||||
Name: "flow-01",
|
||||
DataType: "Float",
|
||||
AccessLevel: TagAccessLevel.ReadWrite,
|
||||
WriteIdempotent: true,
|
||||
PollGroupId: "fast",
|
||||
TagConfig: """
|
||||
{"region":"InputRegisters","address":10,"dataType":"Float32","byteOrder":"WordSwap",
|
||||
"bitIndex":0,"stringLength":0,"writable":false,"scale":3.5,
|
||||
"isHistorized":true,"historianTagname":"MyHist","isArray":true,"arrayLength":4,
|
||||
"alarm":{"alarmType":"LimitAlarm","severity":800,"historizeToAveva":false}}
|
||||
""");
|
||||
|
||||
// A device-root tag (null group path) with only typed columns + no flags.
|
||||
var plainTag = new RawCsvExportTag(
|
||||
TagGroupPath: null,
|
||||
Name: "coil-3",
|
||||
DataType: "Boolean",
|
||||
AccessLevel: TagAccessLevel.Read,
|
||||
WriteIdempotent: false,
|
||||
PollGroupId: null,
|
||||
TagConfig: ModbusModel("Coils", 3, "Bool", "BigEndian", writable: null));
|
||||
|
||||
AssertRoundTrip(DriverTypeNames.Modbus, new[] { richTag, plainTag });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUaClient_and_Galaxy_maps_round_trip()
|
||||
{
|
||||
var opc = new RawCsvExportTag(
|
||||
"Line/Motors", "m1", "Double", TagAccessLevel.ReadWrite, false, null,
|
||||
"""{"nodeId":"nsu=urn:plc;s=Motor1.Speed","isHistorized":true}""");
|
||||
|
||||
AssertRoundTrip(DriverTypeNames.OpcUaClient, new[] { opc });
|
||||
|
||||
var galaxy = new RawCsvExportTag(
|
||||
null, "Reactor_Temp", "Float", TagAccessLevel.Read, false, null,
|
||||
"""{"FullName":"Reactor_001.Temp","historianTagname":"Reactor.Temp"}""");
|
||||
|
||||
AssertRoundTrip(DriverTypeNames.Galaxy, new[] { galaxy });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Export_header_matches_the_driver_column_dictionary()
|
||||
{
|
||||
var csv = RawTagCsvMapper.Export(DriverTypeNames.S7, Array.Empty<RawCsvExportTag>());
|
||||
var rows = CsvParser.Parse(csv);
|
||||
rows.ShouldHaveSingleItem(); // header only
|
||||
rows[0].ShouldBe(CsvColumnMap.HeadersFor(DriverTypeNames.S7).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_typed_column_wins_over_a_conflicting_TagConfigJson_key()
|
||||
{
|
||||
// Base carries address="DB1.DBW99"; the typed Address column carries "DB1.DBW5" — the typed value
|
||||
// must win, and the review row must flag the override. (S7 Address is a string field.)
|
||||
var csv = string.Join("\r\n",
|
||||
"Name,DataType,Address,S7DataType,TagConfigJson",
|
||||
"""flow,Int16,DB1.DBW5,Int16,"{""address"":""DB1.DBW99""}" """.Trim());
|
||||
|
||||
var result = RawTagCsvMapper.Parse(DriverTypeNames.S7, csv);
|
||||
result.FatalError.ShouldBeNull();
|
||||
var row = result.Rows.ShouldHaveSingleItem();
|
||||
row.Ok.ShouldBeTrue();
|
||||
row.TypedOverrodeFallback.ShouldContain("Address");
|
||||
|
||||
var o = JsonNode.Parse(row.ImportRow!.Tag.TagConfig)!.AsObject();
|
||||
o["address"]!.GetValue<string>().ShouldBe("DB1.DBW5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_bad_enum_row_is_flagged_and_blocks_commit()
|
||||
{
|
||||
var csv = string.Join("\r\n",
|
||||
"Name,DataType,Region,Address,ModbusDataType",
|
||||
"t1,Int16,NotARegion,0,Int16");
|
||||
|
||||
var result = RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv);
|
||||
var row = result.Rows.ShouldHaveSingleItem();
|
||||
row.Ok.ShouldBeFalse();
|
||||
row.Error!.ShouldContain("Region");
|
||||
result.CanCommit.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_name_in_the_same_group_is_flagged_on_the_second_row()
|
||||
{
|
||||
var csv = string.Join("\r\n",
|
||||
"Name,DataType,TagGroupPath,NodeId",
|
||||
"dup,Int32,G1,ns=2;s=A",
|
||||
"dup,Int32,G1,ns=2;s=B");
|
||||
|
||||
var result = RawTagCsvMapper.Parse(DriverTypeNames.OpcUaClient, csv);
|
||||
result.Rows[0].Ok.ShouldBeTrue();
|
||||
result.Rows[1].Ok.ShouldBeFalse();
|
||||
result.Rows[1].Error!.ShouldContain("Duplicate");
|
||||
result.CanCommit.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Group_path_prefix_is_prepended_to_each_row()
|
||||
{
|
||||
var csv = string.Join("\r\n",
|
||||
"Name,DataType,TagGroupPath,NodeId",
|
||||
"a,Int32,Sub,ns=2;s=A",
|
||||
"b,Int32,,ns=2;s=B");
|
||||
|
||||
var result = RawTagCsvMapper.Parse(DriverTypeNames.OpcUaClient, csv, groupPathPrefix: "Root");
|
||||
result.Rows[0].ImportRow!.TagGroupPath.ShouldBe("Root/Sub");
|
||||
result.Rows[1].ImportRow!.TagGroupPath.ShouldBe("Root");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------- helpers
|
||||
|
||||
private static void AssertRoundTrip(string driverType, IReadOnlyList<RawCsvExportTag> tags)
|
||||
{
|
||||
var csv = RawTagCsvMapper.Export(driverType, tags);
|
||||
var parsed = RawTagCsvMapper.Parse(driverType, csv);
|
||||
|
||||
parsed.FatalError.ShouldBeNull();
|
||||
parsed.CanCommit.ShouldBeTrue($"every re-imported row should be valid; errors: " +
|
||||
string.Join(" | ", parsed.Rows.Where(r => !r.Ok).Select(r => r.Error)));
|
||||
|
||||
var imported = parsed.ToImportRows();
|
||||
imported.Count.ShouldBe(tags.Count);
|
||||
|
||||
foreach (var original in tags)
|
||||
{
|
||||
var match = imported.SingleOrDefault(r => r.Tag.Name == original.Name);
|
||||
match.ShouldNotBeNull($"tag '{original.Name}' vanished across the round-trip.");
|
||||
|
||||
match!.TagGroupPath.ShouldBe(original.TagGroupPath, $"group path drift for '{original.Name}'.");
|
||||
match.Tag.DataType.ShouldBe(original.DataType);
|
||||
match.Tag.AccessLevel.ShouldBe(original.AccessLevel);
|
||||
match.Tag.WriteIdempotent.ShouldBe(original.WriteIdempotent);
|
||||
match.Tag.PollGroupId.ShouldBe(original.PollGroupId);
|
||||
|
||||
Canonical(match.Tag.TagConfig).ShouldBe(Canonical(original.TagConfig),
|
||||
$"TagConfig drift for '{original.Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical Modbus config via the real model, so the "expected" already carries the model's full key set.
|
||||
private static string ModbusModel(string region, int address, string dataType, string byteOrder, bool? writable)
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("{}");
|
||||
m.Region = Enum.Parse<ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion>(region);
|
||||
m.Address = address;
|
||||
m.DataType = Enum.Parse<ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType>(dataType);
|
||||
m.ByteOrder = Enum.Parse<ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusByteOrder>(byteOrder);
|
||||
m.Writable = writable;
|
||||
return m.ToJson();
|
||||
}
|
||||
|
||||
// Order-independent, numeric-normalised JSON canonicaliser for semantic equality.
|
||||
private static string Canonical(string json)
|
||||
{
|
||||
var node = JsonNode.Parse(json);
|
||||
return Normalize(node)?.ToJsonString() ?? "null";
|
||||
}
|
||||
|
||||
private static JsonNode? Normalize(JsonNode? node)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject obj:
|
||||
{
|
||||
var sorted = new JsonObject();
|
||||
foreach (var key in obj.Select(kvp => kvp.Key).OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
sorted[key] = Normalize(obj[key]?.DeepClone());
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
case JsonArray arr:
|
||||
{
|
||||
var outArr = new JsonArray();
|
||||
foreach (var item in arr)
|
||||
{
|
||||
outArr.Add(Normalize(item?.DeepClone()));
|
||||
}
|
||||
|
||||
return outArr;
|
||||
}
|
||||
|
||||
case JsonValue val:
|
||||
{
|
||||
// Collapse numeric representations (int vs uint vs double 4 vs 4.0) to a canonical form.
|
||||
if (val.GetValueKind() == JsonValueKind.Number &&
|
||||
double.TryParse(val.ToJsonString(), System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var d))
|
||||
{
|
||||
return JsonValue.Create(d);
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
default:
|
||||
return node;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user