using System.Text.Json.Nodes; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// /// Pure mapper for the WP6 "Browse device…" re-target: turns a selected driver-browse leaf into a /// ready for . Under the v3 /// identity contract the tag's identity is its RawPath (device + optional group + name), so the leaf's /// driver reference is written into the driver-typed TagConfig as an ordinary address field /// (nodeId/tagPath/symbolPath/address/attributeRef per driver) — never an /// identity key. The address-field write reuses the same <Driver>TagConfigModel the typed tag /// editors use, so a browse-committed tag round-trips through that editor unchanged. /// public static class RawBrowseCommitMapper { /// /// Maps one selected browse leaf into an import row. /// /// The owning device's driver type (a value). /// The leaf's driver-side full reference (the BrowseNode.NodeId = the /// captured DriverAttributeInfo.FullName) — becomes the driver-typed address field. /// The leaf's browse name. Becomes the raw tag Name after /// coerces it into a legal RawPath segment — a Sparkplug metric name /// legitimately contains / and a Name may not (Gitea #509). The leaf's binding address is /// unaffected: it travels separately into the TagConfig. /// The leaf's name (from the browse /// attribute side-channel), or null when the browser cannot report a type (e.g. the OPC UA Client tree). /// The OPC-UA built-in type name to fall back to when /// is null/unmappable. /// The target TagGroup's device-relative path when committing under a group, /// or null when committing at the device root. Prepended to any mirrored folder path. /// The captured browse-folder nesting above the leaf (root→parent display /// names); mirrored onto nested TagGroups only when is true. /// When true, mirror as nested TagGroups. /// The leaf's structured address (), /// for a driver whose binding is a tuple rather than a single reference string; null for every driver /// whose address is the itself. /// The assembled import row. public static RawTagImportRow MapLeaf( string driverType, string fullName, string browseName, string? driverDataType, string defaultDataType, string? groupPrefix, IReadOnlyList? folderPath, bool createGroups, IReadOnlyDictionary? addressFields = null) { var dataType = MapDataType(driverDataType) ?? defaultDataType; var tagConfig = BuildTagConfig(driverType, fullName, addressFields); var name = ToRawSegment(browseName); var mirrored = createGroups && folderPath is { Count: > 0 } ? string.Join(RawPaths.Separator, folderPath) : null; var groupPath = CombineGroupPath(groupPrefix, mirrored); // Access baseline mirrors manual entry (Read); write-idempotent stays false (unknown at browse time); // no poll-group (batching is authored later). return new RawTagImportRow( groupPath, new RawTagInput(name, dataType, TagAccessLevel.Read, WriteIdempotent: false, PollGroupId: null, tagConfig)); } /// /// Coerces a browse leaf's name into a legal RawPath segment (Gitea #509): trims the ends and /// replaces every with . /// /// The leaf's browse name, as the driver reported it. /// A -clean segment; blank only if the input was blank. /// /// /// The Name and the address are two fields, and only the Name is constrained. A tag Name is /// a RawPath segment, so it may not contain /; the driver-side reference it binds by has no /// such rule and is written into the TagConfig untouched by . /// Refusing the commit outright conflated the two — and it refused a metric the Sparkplug B spec /// makes mandatory on every edge node (Node Control/Rebirth), so the one metric an /// operator is most likely to browse for was the one that could not be committed. /// /// /// This deliberately covers every driver, not just MQTT. The argument is about what a Name /// is, not about who produced it: any browser whose leaf name carries a / hits the same /// wall, and for all of them the binding address travels separately and survives intact. /// /// /// The rule set is 's, not an invented one — empty, /// an embedded separator, and leading/trailing whitespace are exactly what it rejects, so trimming /// and replacing is total rather than a best-effort guess. RawBrowseCommitMapperTests pins /// that by asserting the output validates, so a new rule there fails here instead of silently /// letting an illegal Name through to the import. /// /// /// Sanitising is not injective, so two distinct leaves can land on one Name — see /// , which is why a caller must run that check rather than /// trusting this in isolation. /// /// public static string ToRawSegment(string? browseName) { var trimmed = (browseName ?? "").Trim(); return trimmed.Length == 0 ? "" : trimmed.Replace(RawPaths.Separator, SeparatorReplacement); } /// What substitutes for a separator inside a browse name. private const char SeparatorReplacement = '_'; /// /// Names the browse leaves that would land on one tag Name within one /// target group, or an empty list when the mapped rows are all distinct. /// /// The ORIGINAL leaf names, in the same order as . /// The mapped import rows. /// One operator-facing message per colliding set; empty when there is nothing to refuse. /// /// Sanitising maps many names onto one — A/B and A_B both become A_B — and a /// silent collision is materially worse than the refusal #509 replaced: two distinct metrics would /// become one tag bound to whichever address the import happened to keep. So the coercion is only /// safe when paired with this check. /// /// Scoped per target group, because the Name only has to be unique within its group — the same /// metric name under two devices is not a collision. /// /// /// The message names the specific pair rather than failing the batch anonymously. The commit /// itself stays all-or-nothing, matching the CSV import's staged-review semantics: nothing is /// written while any row is unresolvable. /// /// public static IReadOnlyList DescribeNameCollisions( IReadOnlyList browseNames, IReadOnlyList rows) { ArgumentNullException.ThrowIfNull(browseNames); ArgumentNullException.ThrowIfNull(rows); return rows .Select((row, i) => (row, original: i < browseNames.Count ? browseNames[i] : row.Tag.Name)) .GroupBy(x => (x.row.TagGroupPath ?? "", x.row.Tag.Name), TupleComparer) .Where(g => g.Count() > 1) .Select(g => $"'{g.Key.Item2}' would be the name of {g.Count()} different tags" + (string.IsNullOrEmpty(g.Key.Item1) ? "" : $" in group '{g.Key.Item1}'") + $": {string.Join(", ", g.Select(x => $"'{x.original}'"))}. " + "Their names differ only by characters that are not legal in a tag name. Commit them " + "separately, or author them by manual entry with distinct names.") .ToList(); } /// Ordinal comparer for the (group path, name) collision key — RawPath comparison is ordinal. private static readonly IEqualityComparer<(string, string)> TupleComparer = EqualityComparer<(string, string)>.Create( (a, b) => RawPaths.Comparer.Equals(a.Item1, b.Item1) && RawPaths.Comparer.Equals(a.Item2, b.Item2), x => HashCode.Combine(RawPaths.Comparer.GetHashCode(x.Item1), RawPaths.Comparer.GetHashCode(x.Item2))); /// /// Maps a enum NAME (as reported by the browse attribute side-channel) to the /// OPC-UA built-in type name a carries. Mirrors /// DiscoveredNodeMapper.ToBuiltinTypeString: most names pass through, Float32/Float64 /// become Float/Double, and Reference (a Galaxy attribute ref) carries as String. /// Returns null when the input is null/blank or not a known driver data type (caller supplies a default). /// /// The name, or null/blank. /// The OPC-UA built-in type name, or null when unmappable. public static string? MapDataType(string? driverDataType) { if (string.IsNullOrWhiteSpace(driverDataType) || !Enum.TryParse(driverDataType, ignoreCase: true, out var dt)) return null; return dt switch { DriverDataType.Boolean => "Boolean", DriverDataType.Int16 => "Int16", DriverDataType.Int32 => "Int32", DriverDataType.Int64 => "Int64", DriverDataType.UInt16 => "UInt16", DriverDataType.UInt32 => "UInt32", DriverDataType.UInt64 => "UInt64", DriverDataType.Float32 => "Float", DriverDataType.Float64 => "Double", DriverDataType.String => "String", DriverDataType.DateTime => "DateTime", DriverDataType.Reference => "String", _ => null, }; } /// /// Builds the driver-typed TagConfig JSON for a browse-committed tag: the driver's address field /// from , plus — for a driver that binds by its OWN data-type enum — the /// native type the browse leaf stated (see ). Every other field stays at /// its model default, for the operator to author afterwards. Reuses the /// <Driver>TagConfigModel for driver types that have a typed editor; the model-less Galaxy /// driver gets the canonical camelCase attributeRef key directly. /// /// The data type is not optional decoration for AB CIP / TwinCAT / FOCAS — their tag-definition /// factories decode the wire bytes by this field, not by the raw tag row's OPC UA DataType. A /// committed leaf left at the model default therefore reads real data through the wrong decoder and /// publishes a plausible wrong number rather than an error (Gitea #536). /// /// /// MQTT is the one driver whose address is not a single string — a Sparkplug tag binds by a /// (group, edgeNode, device?, metric) tuple — so it is built from the browse session's stated /// instead. See . /// /// /// The owning device's driver type. /// The leaf's driver-side full reference to write into the address field. /// The leaf's structured address, when the driver binds by a tuple — /// see . Ignored by every single-reference driver. /// The serialised driver-typed TagConfig JSON. public static string BuildTagConfig( string driverType, string fullName, IReadOnlyDictionary? addressFields = null) { var address = fullName ?? ""; if (Is(driverType, DriverTypeNames.Mqtt)) return BuildMqttTagConfig(address, addressFields); if (Is(driverType, DriverTypeNames.OpcUaClient)) return new OpcUaClientTagConfigModel { NodeId = address }.ToJson(); if (Is(driverType, DriverTypeNames.AbCip)) return new AbCipTagConfigModel { TagPath = address, DataType = NativeType(addressFields, AbCipDataType.DInt), }.ToJson(); if (Is(driverType, DriverTypeNames.AbLegacy)) return new AbLegacyTagConfigModel { Address = address }.ToJson(); if (Is(driverType, DriverTypeNames.TwinCAT)) return new TwinCATTagConfigModel { SymbolPath = address, DataType = NativeType(addressFields, TwinCATDataType.DInt), }.ToJson(); if (Is(driverType, DriverTypeNames.FOCAS)) return new FocasTagConfigModel { Address = address, DataType = NativeType(addressFields, FocasDataType.Int32), }.ToJson(); if (Is(driverType, DriverTypeNames.S7)) return new S7TagConfigModel { Address = address }.ToJson(); // MTConnect: the DataItem id. The driver also accepts "address"/"dataItemId", but the typed // editor's canonical spelling is fullName — committing under the generic key below would open // in that editor with an EMPTY id field and blank it on save. if (Is(driverType, DriverTypeNames.MTConnect)) return new MTConnectTagConfigModel { FullName = address }.ToJson(); if (Is(driverType, DriverTypeNames.Galaxy)) return WriteSingleKey("attributeRef", address); // Sql: a browsed leaf is a COLUMN, and a column name alone cannot address a tag — SqlTagConfigModel // needs the table too. SqlBrowseSession therefore travels schema/table/columnName in AddressFields. // Without this branch a Sql commit fell through to the generic "address" key below, which the typed // Sql editor does not read: it would open with empty fields and blank them on save — the exact // failure the MTConnect branch above was added to avoid (deferment.md G-6). Sql IS browsable // (SqlDriverBrowser), so the fallback's "browsable drivers are all handled above" was untrue. if (Is(driverType, DriverTypeNames.Sql)) return BuildSqlTagConfig(address, addressFields); // Unknown/flat-address driver (e.g. Modbus is not browsable): record the reference under a generic // "address" key so nothing is lost. RawBrowseCommitMapperParityTests enumerates DriverTypeNames.All // and holds this line to two claims rather than to the assertion the comment used to make: // - a driver type with a bespoke IDriverBrowser registered by AddAdminUI() — read back from the // real registration, not a list — must NOT reach here; and // - any type that does reach here must be named in that test's GenericKeyExemptions with a reason, // and every name on that list must still reach here (Gitea #558) — an exemption for a driver // that has since gained a branch above is a stale claim, and a silently inert one. // The universal DiscoveryDriverBrowser's arm is not derivable from the AdminUI assembly (it needs // the runtime drivers + the Host's IDriverFactory), so it is covered by the exemption list rather // than mechanically — see the test's remarks for the residual trust that leaves (Gitea #555). return WriteSingleKey("address", address); } /// /// Builds the TagConfig for a browse-committed MQTT leaf, whose address is a /// descriptor rather than a single reference string: topic in Plain mode, and the /// groupId/edgeNodeId/deviceId?/metricName tuple in Sparkplug B mode. /// /// The leaf's browse node id — the Plain-mode fallback address only. /// The structured address the browse session stated. /// The serialised TagConfig JSON. /// /// /// The address is read from , never parsed out of /// . A Sparkplug browse node id is /// {group}/{node}[/{device}]::{metric}, and a metric name legitimately contains / /// (Node Control/Rebirth) — so splitting the id cannot recover the tuple in general, and a /// mapper that guessed would silently bind the wrong metric. The session that decoded the birth /// already holds the decomposition and hands it over; this method only picks the keys it knows. /// /// /// Plain vs Sparkplug is likewise stated, not inferred — the driver type reaching /// here is just Mqtt, and the mode lives on the driver config, not on the tag. The /// producing session knows its own mode and says so by which keys it emits: a /// metricName ⇒ Sparkplug, a topic ⇒ Plain. (The blob deliberately carries no /// mode key: MqttTagDefinitionFactory takes the shape from the driver's mode and /// would ignore one, and MqttTagConfigModel re-infers it from these same keys.) /// /// /// Keys are whitelisted, not splatted: these values came off a broker, and everything /// beyond the address (payload format, JSONPath, QoS, dataType) stays at the driver's own /// defaults for the operator to author afterwards. A leaf with no stated address at all is /// rejected before this is reached — see . /// /// private static string BuildMqttTagConfig(string fullName, IReadOnlyDictionary? addressFields) { if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is { } metricName) { var o = new JsonObject { [MqttTagConfigKeys.GroupId] = TryGetField(addressFields, MqttTagConfigKeys.GroupId) ?? "", [MqttTagConfigKeys.EdgeNodeId] = TryGetField(addressFields, MqttTagConfigKeys.EdgeNodeId) ?? "", [MqttTagConfigKeys.MetricName] = metricName, }; // Absent, not blank, for a node-level metric — the two describe the same scope to the // factory, but only the absent form says "this metric has no device" to a reader. if (TryGetField(addressFields, MqttTagConfigKeys.DeviceId) is { } deviceId) o[MqttTagConfigKeys.DeviceId] = deviceId; return o.ToJsonString(); } // Plain: the session states the topic; the node id is the same value and is the fallback for a // session that stated nothing (a shape DescribeUncommittableLeaf refuses upstream). return WriteSingleKey( MqttTagConfigKeys.Topic, TryGetField(addressFields, MqttTagConfigKeys.Topic) ?? fullName); } /// /// Describes why a selected browse leaf cannot be committed as a working tag, or null when it /// can. The AdminUI calls this before mapping so an unbindable selection fails at commit, in /// words. /// /// The owning device's driver type. /// The leaf's browse name, for the message. /// The structured address the browse session stated, if any. /// The operator-facing reason, or null when the leaf is committable. /// /// /// Exactly two drivers can fail this, and both for the same underlying reason: their binding is a /// TUPLE, so a leaf can be selectable in the tree and still not carry enough to bind. Every /// single-reference driver is unaffected — its address IS the node id. /// /// /// MQTT: a leaf whose attribute lookup returned nothing, so no address was stated. Committing /// it anyway is the defect this whole seam exists to close — a Sparkplug tuple cannot be /// reconstructed from the node id, so the row would deploy clean and then report /// BadNodeIdUnknown forever with nothing to point at. /// /// /// Sql (Gitea #552): a column of a table carrying no date/time column. A wide-row tag must say /// which ROW it reads, and with no timestamp to order by the only remaining selector is a /// whereColumn/whereValue pair — whose value is data, not schema, and therefore /// is not something any browse can know. Refusing here rather than letting the import's own gate /// refuse the blob is what makes the message name the table and the fix; the gate would only report /// that a row selector is missing, on every selected column at once. /// /// public static string? DescribeUncommittableLeaf( string driverType, string browseName, IReadOnlyDictionary? addressFields) { if (Is(driverType, DriverTypeNames.Mqtt)) return DescribeUncommittableMqttLeaf(browseName, addressFields); if (Is(driverType, DriverTypeNames.Sql)) return DescribeUncommittableSqlLeaf(browseName, addressFields); return null; } /// Describes an MQTT leaf that states no address at all, or null when it states one. /// The leaf's browse name, for the message. /// The structured address the browse session stated, if any. /// The operator-facing reason, or null. private static string? DescribeUncommittableMqttLeaf( string browseName, IReadOnlyDictionary? addressFields) { if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is not null) return null; if (TryGetField(addressFields, MqttTagConfigKeys.Topic) is not null) return null; return $"'{browseName}' could not be committed: the browse session reported no MQTT address for it " + "(its attribute lookup returned nothing). Re-open the browser and re-select it, or author " + "the tag manually."; } /// /// Describes a Sql column leaf whose wide-row binding cannot be completed from the catalog, or /// null when it can (Gitea #552). /// /// The leaf's browse name, for the message. /// The structured address the browse session stated, if any. /// The operator-facing reason, or null. private static string? DescribeUncommittableSqlLeaf( string browseName, IReadOnlyDictionary? addressFields) { if (TryGetField(addressFields, SqlBrowseAddressFields.Table) is not { } table) { return $"'{browseName}' could not be committed: the browse session reported no Sql table for it " + "(its attribute lookup returned nothing). Re-open the browser and re-select it, or author " + "the tag manually."; } if (TryGetField(addressFields, SqlBrowseAddressFields.TopByTimestamp) is not null) return null; return $"'{browseName}' could not be committed: '{table}' has no date/time column, so which ROW the " + "tag reads cannot be derived from the schema. Author it in the tag editor with a " + "whereColumn/whereValue row selector — the value it matches on is data, not schema, so the " + "browser cannot supply it."; } /// /// Reads the browse leaf's driver-native data type out of the stated address fields, falling /// back to — the driver model's own default — when the browser /// stated none or stated one this driver's enum does not name. /// /// The driver's own data-type enum. /// The leaf's stated address fields. /// The model default to keep when no usable native type was stated. /// The parsed native type, or . /// /// The type is READ, never derived (Gitea #536). A commit used to leave this at the /// model default, so every browsed AB CIP leaf — a REAL, a BOOL, a STRING — was written as /// DInt and the driver decoded four bytes of a float as an integer: a plausible /// number, no error, wrong value. Deriving it from the leaf's driver-AGNOSTIC /// DriverDataType instead would fix the REAL and still get the integers wrong — /// SInt, Int and DInt all collapse into Int32 on the way out, so /// a 16-bit INT would come back as a 32-bit DINT. The producing driver knows its own type /// and states it; see CapturedTreeBrowseSession.NativeTypeFields. /// /// Case-insensitive because the key travels as an enum NAME, and a driver is free to spell /// it however its own enum does. An unparseable value keeps the default rather than failing /// the commit: the address — the part that cannot be re-derived later — is still correct, /// and the operator can fix the type in the typed editor. /// /// /// A numeric string is refused before parsing. Enum.TryParse accepts "9" /// for any enum and hands back whatever member sits at ordinal 9 — which Enum.IsDefined /// then happily confirms — so a driver that stamped a number would bind a tag to a type nobody /// named. Same trap MTConnectTagConfigModel documents for its own dataType. /// /// private static TEnum NativeType(IReadOnlyDictionary? addressFields, TEnum fallback) where TEnum : struct, Enum => TryGetField(addressFields, "dataType") is { Length: > 0 } raw && !char.IsAsciiDigit(raw[0]) && raw[0] is not ('-' or '+') && Enum.TryParse(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(parsed) ? parsed : fallback; /// Reads one non-blank address field, or null when it is absent or blank. /// The stated address fields, or null. /// The field to read. /// The trimmed value, or null. private static string? TryGetField(IReadOnlyDictionary? fields, string key) => fields is not null && fields.TryGetValue(key, out var v) && !string.IsNullOrWhiteSpace(v) ? v.Trim() : null; /// /// Combines a target-group path prefix with an optional mirrored sub-path, matching the WP5 CSV import /// combine semantics: blank collapses to null, and a present prefix + suffix join with the RawPath /// separator. Returns null when both are absent (⇒ commit at the device root). /// /// The target TagGroup's device-relative path, or null/blank for the device root. /// The mirrored folder sub-path, or null/blank. /// The combined device-relative group path, or null for the device root. public static string? CombineGroupPath(string? prefix, string? suffix) { prefix = string.IsNullOrWhiteSpace(prefix) ? null : prefix.Trim(); suffix = string.IsNullOrWhiteSpace(suffix) ? null : suffix.Trim(); if (prefix is null) return suffix; return suffix is null ? prefix : $"{prefix}{RawPaths.SeparatorString}{suffix}"; } private static bool Is(string driverType, string name) => string.Equals(driverType, name, StringComparison.OrdinalIgnoreCase); /// /// Builds a SqlTagConfigModel blob from a browsed COLUMN leaf. Maps to /// SqlTagModel.WideRow — the browse tree is schema → table → column, which is precisely the /// wide-row shape (one row holds many signals as columns). A KeyValue (EAV) tag cannot be derived /// from a browse pick because its key VALUE is data, not schema, so the operator authors that in the /// typed editor. /// Falls back to the generic key when the fields are absent — an older browser build, or a /// hand-made selection — rather than emitting a half-built Sql blob. /// /// The leaf's browse node id — the column-name fallback only. /// The structured address the browse session stated. /// The serialised TagConfig JSON. /// /// /// A wide-row tag needs three things, not two: the table, the column, and WHICH ROW /// (Gitea #552). This branch used to emit only the first two, and a selector-less wide-row blob is /// refused by SqlEquipmentTagParser and by SqlTagConfigModel.Validate alike — so every /// browse-committed Sql tag was unreadable, first silently (BadNodeIdUnknown forever) and then, /// once #551 put the gate on the import, loudly at commit. /// /// /// The row selector is READ from the session's stated fields, never inferred here — same rule /// as MQTT's Sparkplug tuple. The session holds the table's column metadata and names a date/time /// column by TYPE (see SqlBrowseSession.PreferredTimestampColumn); this mapper has only a /// column name and a table name, from which any selector it invented would be a guess about data. /// /// /// When no candidate was stated the blob stays selector-less on purpose. The alternatives are /// worse: a fabricated selector binds the tag to a row nobody chose, and dropping to the generic /// {"address": …} key throws away the table the session did know. Kept in the wide-row shape, /// the blob opens in the typed editor with everything but the selector filled in — and the commit /// itself does not reach here, because refuses that leaf in /// words first. /// /// /// The blob's top-level timestampColumn (the SOURCE-timestamp column, which makes the driver /// stamp values with the row's time instead of the poll time) is deliberately left unset: ordering by /// a column is not a claim that it timestamps the value, and turning it on silently would change every /// browse-committed tag's published SourceTimestamp. /// /// private static string BuildSqlTagConfig(string address, IReadOnlyDictionary? addressFields) { if (addressFields is null || !addressFields.TryGetValue(SqlBrowseAddressFields.Table, out var table) || string.IsNullOrWhiteSpace(table)) { return WriteSingleKey("address", address); } addressFields.TryGetValue(SqlBrowseAddressFields.Schema, out var schema); addressFields.TryGetValue(SqlBrowseAddressFields.ColumnName, out var columnName); // Qualify the table with its schema when the schema is not the default, so a tag authored against // "sales.Readings" cannot silently resolve to "dbo.Readings". var qualified = !string.IsNullOrWhiteSpace(schema) && !string.Equals(schema, "dbo", StringComparison.OrdinalIgnoreCase) ? $"{schema}.{table}" : table; return new SqlTagConfigModel { Model = SqlTagModel.WideRow, Table = qualified, ColumnName = string.IsNullOrWhiteSpace(columnName) ? address : columnName, // Null when the session stated none: SqlRowSelectorModel.ToJsonObject then returns null and the // "rowSelector" key is omitted entirely, rather than written as an empty or blank-valued object. RowSelector = new SqlRowSelectorModel { TopByTimestamp = TryGetField(addressFields, SqlBrowseAddressFields.TopByTimestamp), }, }.ToJson(); } private static string WriteSingleKey(string key, string value) { var o = new JsonObject { [key] = value }; return o.ToJsonString(); } }