diff --git a/CLAUDE.md b/CLAUDE.md index c93dd162..acc8fe69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -215,6 +215,8 @@ Address pickers in AdminUI support live browse for OpcUaClient and Galaxy driver The AdminUI's global **UNS** page (`/uns`) is the single surface for managing the unified namespace fleet-wide (Area → Line → Equipment → Tag/VirtualTag), replacing the old per-cluster UNS/Equipment/Tags tabs. See `docs/Uns.md`. +**v3 (Batch 2): device I/O is authored in the `/raw` project tree** (`GlobalRaw.razor` → `RawTree.razor`, `IRawTreeService`) — a cluster-rooted `Folder → Driver → Device → TagGroup → Tag` hierarchy with per-node context menus (the reusable `ContextMenu`), lazy loading, driver/device config modals (endpoint moved to `Device.DeviceConfig`; Test-connect probes the `DriverDeviceConfigMerger`-merged config), manual-entry + CSV import/export (RFC-4180, staged review grid, all-or-nothing), and browse-commit of discovered leaves into raw tags. The routed `/clusters/{id}/drivers` flow (`DriverTypePicker`, `DriverEditRouter`, the 8 `*DriverPage` shells, the Drivers list) is **retired** — its form bodies live on inside the `/raw` modals. The `DriverType` dispatch maps are keyed off the `DriverTypeNames` constants (fixing the `TwinCat`/`Focas` drift). A `Calculation` pseudo-driver (`DriverTypeNames.Calculation`, `IDependencyConsumer`, deploy-time `scriptId`-existence + Tarjan cycle gates) computes signal-level tags from other tags' RawPaths. See `docs/Raw.md`. + The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`. ## Scripting / Script Editor diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 14ad26d1..a1090eab 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -21,6 +21,7 @@ + @@ -81,6 +82,7 @@ + diff --git a/docs/Raw.md b/docs/Raw.md new file mode 100644 index 00000000..5bc39512 --- /dev/null +++ b/docs/Raw.md @@ -0,0 +1,91 @@ +# The Raw project tree (`/raw`) + +v3 authors device I/O in a **Raw project tree** — a cluster-rooted, Kepware-style hierarchy +that is the single surface for driver/device/tag authoring. It is the peer of the `/uns` +unified-namespace page; the two OPC UA namespaces (`ns=Raw` / `ns=UNS`) light up in Batch 4. + +``` +Enterprise → Cluster → Folder(s) → Driver → Device → TagGroup(s) → Tag +``` + +Every node's identity is its **RawPath** (`////`, +cluster-scoped, `/`-separated, ordinal). Names are validated as RawPath segments (no `/`, no +leading/trailing whitespace) at authoring and at the deploy gate. + +## The tree + +`/raw` (`GlobalRaw.razor` → `RawTree.razor`, backed by `IRawTreeService`) lazily loads each +container level on expand (unlike `/uns`, which eager-loads). Each node has a right-click +context menu **and** a `⋯` fallback (the reusable `ContextMenu` component): + +| Node | Actions | +|---|---| +| Cluster | New folder · New driver | +| Folder | New folder · New driver · Rename · Delete | +| Driver | Configure · New device · Enable/Disable · Rename · Delete | +| Device | Configure (endpoint + **Test connect**) · New tag-group · Add tags ▸ · Delete | +| TagGroup | New group · Add tags ▸ · Rename · Delete | +| Tag | Edit · Delete | + +Deletes are blocked while a node has children (or, for a raw tag, while a `UnsTagReference` +points at it — the error names the referencing equipment). Renames return non-blocking +**warnings**: renaming a node changes the RawPath of every historized / UNS-referenced tag +beneath it (its historian tagname / UNS projection path moves). + +## Endpoint → DeviceConfig (the channel/device split) + +v3 moves the **connection endpoint** off the driver and onto the **Device** (`DeviceConfig`), +mirroring Kepware's channel/device split. The driver's `DriverConfig` holds protocol/channel +settings; the device's `DeviceConfig` holds host/port/endpoint. At deploy/probe time +`DriverDeviceConfigMerger` merges the device's endpoint up into the driver config, and Test +connect (inside the **Device** modal) probes that merged config. Driver forms no longer +serialize the endpoint keys, so `DeviceConfig` is the single source of truth. + +## Authoring tags + +**Add tags ▸** on a Device or TagGroup offers: + +- **Manual entry** — a grid of Name / DataType / AccessLevel / WriteIdempotent / PollGroup + + the driver-typed `TagConfig` editor per row. +- **Import/Export CSV** — a staged flow (upload → parse → **review grid** with per-row + verdicts → commit) over an RFC-4180 parser. Columns: the common set + (`Name, TagGroupPath, DataType, AccessLevel, WriteIdempotent, PollGroup, IsHistorized, + HistorianTagname, IsArray, ArrayLength, Alarm.*, TagConfigJson`) plus the per-driver typed + columns (e.g. Modbus `Region, Address, ModbusDataType, ByteOrder, …`). `TagGroupPath` + auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in + the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit. + Export round-trips the same shape. +- **Browse device…** — enabled per the two-tier resolution (bespoke `IDriverBrowser` → + universal `DiscoveryDriverBrowser` when the driver's `ITagDiscovery.SupportsOnlineDiscovery` + is true → grayed out otherwise). Multi-selected leaves become raw `Tag` rows under the + target, with the browsed reference written into the driver-typed `TagConfig` **address + field** (`nodeId` / `tagPath` / …), never an identity key. An opt-in "create matching + tag-groups" toggle mirrors the browse folder nesting. The browser dials a real ephemeral + driver against the **merged** Driver+Device config. + +## The `Calculation` driver + +Signal-level calculated tags are ordinary raw tags bound to a `Calculation` driver +(`DriverTypeNames.Calculation`), computed by C# scripts that read other tags' live values via +`ctx.GetTag("")`. See `docs/plans/2026-07-15-calculation-driver-mini-design.md` for +the full design. Highlights: + +- One auto-created default `Engine` device; per-tag `TagConfig` is + `{ "scriptId": "…", "changeTriggered": true, "timerIntervalMs": 5000 }`. +- The host feeds dependency values via the new `IDependencyConsumer` capability + a + `DependencyConsumerMuxAdapter` on the per-node dependency mux; calc-of-calc chains work + because a calc output re-enters the mux. The driver rebuilds its tag/dependency table on + every redeploy (`ReinitializeAsync`) and re-registers its refs after the delta applies. +- **Deploy gates** (`DraftValidator`): `scriptId` existence, and a hard **cycle gate** + (`DependencyGraph.DetectCycles` over calc→calc edges; cross-driver refs are terminal) that + rejects an A→B→A oscillation loop naming the members. Compile is not hard-gated (VirtualTag + parity); a runtime compile/throw/timeout lands as Bad quality + a `script-logs` entry. + +## Retirement note + +The routed `/clusters/{id}/drivers` driver-authoring flow (`DriverTypePicker`, +`DriverEditRouter`, the 8 per-type `*DriverPage` shells, and the per-cluster Drivers list) is +**retired** — authoring lives in `/raw`. The extracted driver/device **form bodies** live on +inside the `/raw` modals. The `DriverType` dispatch maps (`TagConfigEditorMap`, +`TagConfigValidator`, `EquipmentTagConfigInspector`) are keyed off the single-source-of-truth +`DriverTypeNames` constants (fixing the historical `TwinCat`/`Focas` drift). diff --git a/docs/plans/2026-07-16-v3-batch2-PR.md b/docs/plans/2026-07-16-v3-batch2-PR.md new file mode 100644 index 00000000..4ae5656a --- /dev/null +++ b/docs/plans/2026-07-16-v3-batch2-PR.md @@ -0,0 +1,74 @@ +# v3 Batch 2 — `/raw` project-tree AdminUI + `Calculation` driver + +Implements `docs/plans/2026-07-15-v3-batch2-raw-ui-calculation-plan.md` (Track 0 + WP1–WP8), +executed via the `/v3-batch 2` coordinator: contracts-first fan-out, worktree-isolated Opus +agents per wave, a code-reviewer pass + green build/test after each wave, then the +non-negotiable 7-item live gate on docker-dev. + +## What landed + +- **Track 0** — reusable `ContextMenu` (right-click + `⋯`, keyboard/focus); RFC-4180 + `CsvParser`/`CsvWriter`; `DriverTypeNames` constants + a reflection-discovery guard; + `CalculationEvaluator` core (VirtualTag-evaluator parity). +- **Wave A** — `IRawTreeService`/`RawTreeService` (lazy tree + mutations, integrity guards, + rename-warnings); `/raw` page + lazy `RawTree` with per-node context menus. +- **Wave B** — driver→embeddable-form refactor + `DriverConfigModal`/`DeviceModal` + (endpoint→`DeviceConfig`, Test-connect via merged config); manual tag entry + tag edit + modal + `Calculation` tag editor; CSV import (staged review grid) / export; all wired into + the tree (name/confirm/driver-type dialogs + post-mutation refresh). +- **Wave C** — browse re-target (two-tier gate, merged-config feed, commit → raw tags with + address-field mapping + folder mirroring); `Calculation` driver (`IDependencyConsumer` + + mux adapter, timer + change triggers, `DraftValidator` scriptId + cycle gates); + `DriverTypeNames` rewire + retirement of the routed driver flow. + +**Address space stays dark** (values light up in Batch 4). Full solution builds 0 errors; +`AdminUI.Tests` 638 passed / 3 skipped, `Runtime.Tests` 357 passed, driver + guard suites green. + +## Reviewer findings fixed in-branch + +- **Wave B H1** — driver forms still serialized the endpoint into `DriverConfig`; stripped + the endpoint keys (Modbus/S7/OpcUaClient) so `DeviceConfig` is the sole source (a 2nd + device on a single-Host driver no longer reverts to `127.0.0.1`). +- **Wave C HIGH** — `CalculationDriver.ReinitializeAsync` ignored the new config, so calc + tag/script edits were silently inert after a redeploy and the re-register loop was + decorative. Now rebuilds the tag/dependency table on reinit and re-registers deps after + the delta applies (`DeltaApplied` notification, race-free). New redeploy test proves it. +- Wave-A/B/0 mediums (auto-expand, friendly create-race/delete failures, discovery-driven + guard, CSV comment). Deferred (documented): pre-existing Modbus/S7 form `TimeSpan`↔`*Ms` + DTO mismatch; CSV alarm-subfield/flag-precedence edge cases; OpcUaClient endpoint + precedence; unused `LoadMergedProbeConfigAsync`; refresh collapses sibling expansion. + +## Live `/run` gate (docker-dev `:9200`, both central nodes rebuilt) + +1. **Authoring + Test-connect** — `/raw` → New Modbus driver `gate-modbus` (form body renders, + `PLC family`/`MELSEC` enum dropdowns correct) → Device1 endpoint `10.100.0.35:5020` in + `DeviceConfig` → **Test connect OK · 94 ms** against the live Modbus fixture; SQL confirms + endpoint in `DeviceConfig` (`{"Host":"10.100.0.35","Port":5020,"UnitId":1}`). +2. **Historian-tagname deploy block** — a historized tag with a 260-char effective tagname → + `POST /api/deployments` **422** `[HistorianTagnameTooLong] tag 'TAG-gatelong' … is 260 chars + (max 255)`; shortened override → **202 Accepted**. +3. **CSV round-trip + bad-enum** — export produces the exact column dictionary + (`…,Region,Address,ModbusDataType,ByteOrder,…`); importing 1 valid + 1 bad-enum row → + review grid "2 rows, 1 valid, 1 invalid", row-2 error *"Column 'ModbusDataType': + 'NOTATYPE' is not a valid ModbusDataType (expected one of: Bool, Int16, UInt16, …)"*, + **Commit disabled** (all-or-nothing; SQL confirms nothing committed); a valid-only import → + Commit → `ImportedTag` lands with typed columns assembled into `TagConfig`. +4. **Browse-commit** — OpcUaClient device → opc-plc (`opc.tcp://10.100.0.35:50000`): two-tier + gate enabled browse, merged config connected, real tree walked (NetworkSet/…/OpcPlc); + multi-selected 2 leaves with "create matching tag-groups" → raw tags land under the + mirrored `Basic` group with DataType + the browse ref in the `nodeId` address field + (`nsu=…;s=AlternatingBoolean`), not an identity key. +5. **Calculation deploy + cycle gate** — a calc tag reading a Modbus RawPath deploys **202**; + a 2-cycle (`calc1/Engine/A`↔`calc1/Engine/B`) → **422** `[CalculationDependencyCycle] … + (members: calc1/Engine/A, calc1/Engine/B)`; clean redeploy 202, no crash loops (both + central nodes stable; only a deploy-reject WRN in the log). +6. **Rename warning** — renaming `gate-modbus`→`gate-modbus-renamed` fired the warning modal + *"1 historized tag beneath this node will change its RawPath (historian tagname)…"*. +7. **Retired routes** — `/clusters/{id}/drivers`, `…/new`, `…/new/{slug}`, `…/{id}` all + return **404**; `/raw` + `/uns` still 200. + +## Docs + +`docs/Raw.md` (new — the `/raw` authoring tree + endpoint split + CSV + Calculation + +retirement note); `CLAUDE.md` AdminUI section updated; the Calculation mini-design remains the +driver's authority. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvParser.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvParser.cs new file mode 100644 index 00000000..1eefc2f0 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvParser.cs @@ -0,0 +1,293 @@ +using System.Globalization; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Csv; + +/// +/// A pure, allocation-lean RFC 4180 CSV reader with no file/stream I/O of its own — it consumes a +/// or a and yields rows of fields. This is the single +/// CSV-reading authority in the tree; keep the RFC contract pinned here (and mirrored in +/// ) rather than re-deriving it at each call site. +/// +/// +/// Faithful to RFC 4180. The grammar handled: +/// +/// Fields are separated by the delimiter (default ','); records are separated by +/// newlines. +/// A field may be quoted with double-quotes ("). A quoted field may contain the +/// delimiter, CR, LF, and CRLF literally, and represents a literal double-quote by doubling it +/// ("""). +/// An unquoted field runs literally up to the next delimiter or newline; leading and +/// trailing spaces are preserved (RFC 4180 §2.4 — spaces are part of a field). +/// CRLF, bare LF, and bare CR are all accepted as record terminators. +/// +/// Empty-line policy. Faithful to the RFC: a line with no characters yields a single row +/// containing one empty field ([""]); it is NOT silently dropped. Callers that want blank lines +/// skipped should filter the result (e.g. rows.Where(r => r.Length > 1 || r[0].Length > 0)). +/// A trailing record terminator at end-of-input does NOT produce a phantom empty final row, and its +/// absence does not lose the last row. +/// Malformed-input policy. This parser is strict. It throws +/// (carrying a 1-based line/column position) for the malformed +/// shapes RFC 4180 forbids: (1) a bare double-quote inside an otherwise-unquoted field (e.g. +/// ab"c), (2) a stray character after a closing quote other than the delimiter or a newline +/// (e.g. "ab"c), and (3) an unterminated quoted field at end-of-input. Strictness is deliberate +/// — silent lenient recovery hides data-shape bugs in imported files. +/// +public static class CsvParser +{ + private const char Quote = '"'; + private const char Cr = '\r'; + private const char Lf = '\n'; + + /// + /// Parses fully into rows of fields. Convenience wrapper over + /// ; materialises the whole document. + /// + /// The CSV document. null is treated as empty. + /// The field separator (default comma). + /// The rows, each an array of field values. Empty input yields zero rows. + /// The input violates the strict RFC 4180 grammar. + public static IReadOnlyList Parse(string? text, char delimiter = ',') + { + using var reader = new StringReader(text ?? string.Empty); + var rows = new List(); + foreach (var row in Parse(reader, delimiter)) + { + rows.Add(row); + } + + return rows; + } + + /// + /// Streams rows from lazily — a single forward pass, one row + /// materialised at a time. The reader is not disposed by this method. + /// + /// The source. Read to end-of-input. + /// The field separator (default comma). + /// A lazily-evaluated sequence of rows; each row is a freshly allocated field array. + /// is null. + /// is a quote, CR, or LF. + /// The input violates the strict RFC 4180 grammar. + public static IEnumerable Parse(TextReader reader, char delimiter = ',') + { + ArgumentNullException.ThrowIfNull(reader); + if (delimiter is Quote or Cr or Lf) + { + throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter)); + } + + return Iterate(reader, delimiter); + } + + private static IEnumerable Iterate(TextReader reader, char delimiter) + { + var field = new StringBuilder(); + var row = new List(); + + // True once the current row has produced any field boundary or content — i.e. once we've seen a + // char that commits us to emitting at least one field. Reset to false right after a record + // terminator closes a row, so a trailing newline at EOF does NOT synthesise a phantom row. + var rowOpen = false; + + // 1-based cursor, maintained for FormatException messages. + var line = 1; + var col = 0; + + int read; + while ((read = reader.Read()) != -1) + { + var c = (char)read; + col++; + + if (c == Quote) + { + if (field.Length != 0) + { + throw new FormatException( + $"Unexpected double-quote inside an unquoted field at line {line}, column {col}. " + + "A field is quoted only when the quote is its first character."); + } + + rowOpen = true; + + // Consume the quoted body; the opening quote has been read. + ReadQuotedField(reader, field, ref line, ref col); + + // A closing quote must be followed by the delimiter, a newline, or EOF. + var next = reader.Peek(); + if (next == -1) + { + row.Add(field.ToString()); + field.Clear(); + yield return row.ToArray(); + row.Clear(); + rowOpen = false; + yield break; + } + + var nc = (char)next; + if (nc == delimiter) + { + reader.Read(); + col++; + row.Add(field.ToString()); + field.Clear(); + continue; + } + + if (nc is Cr or Lf) + { + row.Add(field.ToString()); + field.Clear(); + ConsumeNewline(reader, ref line, ref col); + yield return row.ToArray(); + row.Clear(); + rowOpen = false; + continue; + } + + throw new FormatException( + $"Unexpected character '{nc}' after closing quote at line {line}, column {col + 1}. " + + "A quoted field must be followed by a delimiter, a newline, or end-of-input."); + } + + if (c == delimiter) + { + row.Add(field.ToString()); + field.Clear(); + rowOpen = true; + continue; + } + + if (c is Cr or Lf) + { + row.Add(field.ToString()); + field.Clear(); + if (c == Cr && reader.Peek() == Lf) + { + reader.Read(); + } + + line++; + col = 0; + yield return row.ToArray(); + row.Clear(); + rowOpen = false; + continue; + } + + field.Append(c); + rowOpen = true; + } + + // EOF: emit a trailing row only if content is pending. A clean terminator already cleared rowOpen. + if (rowOpen || field.Length != 0 || row.Count != 0) + { + row.Add(field.ToString()); + yield return row.ToArray(); + } + } + + /// + /// Reads the body of a quoted field into . The opening quote has already + /// been consumed. On return the reader sits immediately after the closing quote. + /// + private static void ReadQuotedField(TextReader reader, StringBuilder field, ref int line, ref int col) + { + var openLine = line; + var openCol = col; + + int read; + while ((read = reader.Read()) != -1) + { + var c = (char)read; + col++; + + if (c == Quote) + { + if (reader.Peek() == Quote) + { + reader.Read(); + col++; + field.Append(Quote); + continue; + } + + return; // closing quote + } + + if (c == Lf) + { + line++; + col = 0; + } + + field.Append(c); + } + + throw new FormatException( + $"Unterminated quoted field opened at line {openLine}, column {openCol}: reached end-of-input " + + "before the closing double-quote."); + } + + /// Consumes a newline (CRLF, CR, or LF) whose first character has been peeked but not read. + private static void ConsumeNewline(TextReader reader, ref int line, ref int col) + { + var first = reader.Read(); + if (first == Cr && reader.Peek() == Lf) + { + reader.Read(); + } + + line++; + col = 0; + } + + /// + /// Header-aware convenience: parses and maps every subsequent row onto the + /// first (header) row's field names. Thin wrapper over . + /// + /// The CSV document; the first row is treated as the header. + /// The field separator (default comma). + /// + /// One dictionary per data row, keyed by header name (ordinal, case-sensitive). A row shorter than + /// the header maps only the columns present; a column beyond the header's width is keyed by its + /// 0-based index rendered as a string. An empty document yields zero rows. + /// + /// The input violates the strict RFC 4180 grammar, or the header contains a duplicate column name. + public static IReadOnlyList> ParseWithHeader(string? text, char delimiter = ',') + { + var rows = Parse(text, delimiter); + if (rows.Count == 0) + { + return Array.Empty>(); + } + + var header = rows[0]; + var seen = new HashSet(StringComparer.Ordinal); + foreach (var name in header) + { + if (!seen.Add(name)) + { + throw new FormatException($"Duplicate header column name '{name}'."); + } + } + + var result = new List>(rows.Count - 1); + for (var i = 1; i < rows.Count; i++) + { + var cells = rows[i]; + var map = new Dictionary(cells.Length, StringComparer.Ordinal); + for (var c = 0; c < cells.Length; c++) + { + var key = c < header.Length ? header[c] : c.ToString(CultureInfo.InvariantCulture); + map[key] = cells[c]; + } + + result.Add(map); + } + + return result; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvWriter.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvWriter.cs new file mode 100644 index 00000000..2e654c74 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvWriter.cs @@ -0,0 +1,142 @@ +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Csv; + +/// +/// A pure RFC 4180 CSV writer with no file/stream I/O of its own — it renders rows of fields to a +/// or a . The inverse of : +/// CsvParser.Parse(CsvWriter.WriteToString(rows)) reproduces rows exactly for any field +/// content. +/// +/// +/// Quote-on-demand. A field is wrapped in double-quotes only when it must be — i.e. when +/// it contains the delimiter, a double-quote, CR, or LF — and internal double-quotes are doubled +/// ("""). Fields that need no quoting are emitted verbatim, so ordinary values stay +/// human-readable. Pass quoteAllFields: true to force every field quoted. +/// Newline. The record terminator defaults to CRLF (\r\n) per RFC 4180 and is +/// configurable. No terminator is written after the final row (matching the parser's +/// no-phantom-trailing-row contract, so a round-trip is exact). +/// +public static class CsvWriter +{ + private const char Quote = '"'; + private const char Cr = '\r'; + private const char Lf = '\n'; + + /// The RFC 4180 record terminator, "\r\n". The default newline for every write. + public const string Crlf = "\r\n"; + + /// + /// Renders to a CSV string. + /// + /// The rows to write; each inner sequence is one record's fields. A null field is written as empty. + /// The field separator (default comma). + /// The record terminator between rows (default CRLF). Not appended after the last row. + /// When true, every field is quoted regardless of content. + /// The CSV text. An empty yields the empty string. + /// is null. + /// is a quote, CR, or LF. + public static string WriteToString( + IEnumerable> rows, + char delimiter = ',', + string newline = Crlf, + bool quoteAllFields = false) + { + var sb = new StringBuilder(); + using var writer = new StringWriter(sb); + Write(writer, rows, delimiter, newline, quoteAllFields); + return sb.ToString(); + } + + /// + /// Writes to . The writer is not disposed or + /// flushed by this method. + /// + /// The destination. + /// The rows to write; each inner sequence is one record's fields. A null field is written as empty. + /// The field separator (default comma). + /// The record terminator between rows (default CRLF). Not appended after the last row. + /// When true, every field is quoted regardless of content. + /// or is null. + /// is a quote, CR, or LF. + public static void Write( + TextWriter writer, + IEnumerable> rows, + char delimiter = ',', + string newline = Crlf, + bool quoteAllFields = false) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(rows); + if (delimiter is Quote or Cr or Lf) + { + throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter)); + } + + var firstRow = true; + foreach (var row in rows) + { + if (!firstRow) + { + writer.Write(newline); + } + + firstRow = false; + + WriteRow(writer, row, delimiter, quoteAllFields); + } + } + + private static void WriteRow(TextWriter writer, IEnumerable row, char delimiter, bool quoteAllFields) + { + ArgumentNullException.ThrowIfNull(row); + + var firstField = true; + foreach (var field in row) + { + if (!firstField) + { + writer.Write(delimiter); + } + + firstField = false; + + WriteField(writer, field ?? string.Empty, delimiter, quoteAllFields); + } + } + + private static void WriteField(TextWriter writer, string field, char delimiter, bool quoteAllFields) + { + if (!quoteAllFields && !NeedsQuoting(field, delimiter)) + { + writer.Write(field); + return; + } + + writer.Write(Quote); + foreach (var ch in field) + { + if (ch == Quote) + { + writer.Write(Quote); // double an internal quote + } + + writer.Write(ch); + } + + writer.Write(Quote); + } + + private static bool NeedsQuoting(string field, char delimiter) + { + foreach (var ch in field) + { + if (ch == delimiter || ch == Quote || ch == Cr || ch == Lf) + { + return true; + } + } + + return false; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs index 9fa9dad7..4bed38ba 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs @@ -55,6 +55,10 @@ public sealed class DraftSnapshot public IReadOnlyList VirtualTags { get; init; } = []; /// Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set. public IReadOnlyList ScriptedAlarms { get; init; } = []; + + /// User-authored scripts (shared by VirtualTags, ScriptedAlarms, and Calculation tags). Drives + /// the WP7 Calculation gates: scriptId existence + the calc→calc dependency-cycle check. + public IReadOnlyList