feat(adminui): B2-WP5 CSV tag import/export + review grid
Staged CSV tag import (upload -> parse -> review grid with per-row verdicts -> all-or-nothing commit) and export for the /raw tree Device/TagGroup. - CsvColumnMap: per-driver typed column <-> TagConfig JSON maps (model-backed for the 7 typed drivers, raw-key for Galaxy/Calculation) + the common column dictionary; typed columns merge OVER the TagConfigJson fallback (typed wins). - RawTagCsvMapper: pure parse (review rows + verdicts + typed/fallback provenance + intra-batch dup detection) and export (residual TagConfigJson) over the T0-2 CsvParser/CsvWriter. - RawTagCsvExportReader: DbContext-backed read helper (no IRawTreeService extension) enumerating a device's tags + reconstructing group paths. - RawCsvImportModal + RawCsvExportModal (data-URI download). - Reflection guard test (typed column -> real model property + batch-plan dictionary parity) + export->import round-trip property test. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user