using System.Text.Json; using System.Text.Json.Serialization; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.Transport.Serialization; namespace ZB.MOM.WW.ScadaBridge.Transport.Import; /// /// Compares an incoming bundle DTO against the existing entity in the target /// database and produces a single that /// classifies the conflict and (for Modified) carries a coarse field /// diff in JSON. /// /// "Coarse" means: each persistent field is compared as a value; differing /// non-code fields appear in changes with old/new values. Code / large- /// text fields (script bodies, API-method scripts) instead carry a structured /// per-line Myers diff: the / /// keep a compact <N lines> /// summary for fallback rendering, and a /// payload carries the hunk lines (+/-/context) plus add/remove totals and a /// truncation flag. The diff is size-capped via 's /// maxLines cap so stays /// bounded. /// /// /// Entity versions are not yet tracked on the POCOs, so the /// / /// fields are always null here. They are reserved for a future /// optimistic-concurrency feature. /// /// public sealed class ArtifactDiff { private static readonly JsonSerializerOptions DiffJsonOptions = new() { WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; // ---- Templates ---- /// /// Compares an incoming template against the existing template in the database. /// /// The incoming template from the bundle. /// The existing template in the database, or null if new. /// An import preview item describing the conflict type and differences. public ImportPreviewItem CompareTemplate(TemplateDto incoming, Template? existing) { ArgumentNullException.ThrowIfNull(incoming); if (existing is null) { return New("Template", incoming.Name); } var changes = new List(); AddIfDifferent(changes, "Description", existing.Description, incoming.Description); AddIfDifferent(changes, "FolderName", FolderNameOf(existing), incoming.FolderName); AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing), incoming.BaseTemplateName); // Bundles reference the alarm on-trigger script by name; resolve the // persisted FK back to a name over this template's own scripts so the // shared equality can compare it. See TemplateChildEquality. var scriptNameById = TemplateChildEquality.ScriptNameResolver(existing.Scripts); // Children: compare each child collection by name. We track which names // were added, which were removed, and which existed on both sides but // diverged in body. We use coarse value equality / line counts for // scripts so the diff JSON stays under a few KB per item. Change // detection is single-sourced through TemplateChildEquality so the diff // can never disagree with what an Overwrite sync writes (#05-T6). DiffChildren( existing.Attributes, incoming.Attributes, e => e.Name, i => i.Name, TemplateChildEquality.AttributesEqual, "Attributes", changes); DiffChildren( existing.Alarms, incoming.Alarms, e => e.Name, i => i.Name, (e, i) => TemplateChildEquality.AlarmsEqual(e, i, scriptNameById), "Alarms", changes); DiffScriptChildren(existing.Scripts, incoming.Scripts, changes); // Compositions diff by InstanceName since ComposedTemplateId vs // ComposedTemplateName aren't directly comparable. The bundle side // already serializes to the name form, so the comparison reduces to // (InstanceName, ComposedTemplateName) tuple equality. DiffChildren( existing.Compositions, incoming.Compositions, e => e.InstanceName, i => i.InstanceName, (e, i) => CompositionTargetNameOf(e) == i.ComposedTemplateName, "Compositions", changes); DiffChildren( existing.NativeAlarmSources, incoming.NativeAlarmSources, e => e.Name, i => i.Name, TemplateChildEquality.NativeAlarmSourcesEqual, "NativeAlarmSources", changes); return BuildItem("Template", incoming.Name, changes); } /// /// Compares an incoming shared script against the existing shared script in the database. /// /// The incoming shared script from the bundle. /// The existing shared script in the database, or null if new. /// An import preview item describing the conflict type and differences. public ImportPreviewItem CompareSharedScript(SharedScriptDto incoming, SharedScript? existing) { ArgumentNullException.ThrowIfNull(incoming); if (existing is null) return New("SharedScript", incoming.Name); var changes = new List(); AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions); AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition); AddCodeChangeIfDifferent(changes, "Code", existing.Code, incoming.Code); return BuildItem("SharedScript", incoming.Name, changes); } /// /// Compares an incoming external system against the existing external system in the database. /// /// The incoming external system from the bundle. /// The existing external system in the database, or null if new. /// The existing external system methods, or null if new. /// An import preview item describing the conflict type and differences. public ImportPreviewItem CompareExternalSystem(ExternalSystemDto incoming, ExternalSystemDefinition? existing, IReadOnlyList? existingMethods) { ArgumentNullException.ThrowIfNull(incoming); if (existing is null) return New("ExternalSystem", incoming.Name); var changes = new List(); AddIfDifferent(changes, "BaseUrl", existing.EndpointUrl, incoming.BaseUrl); AddIfDifferent(changes, "AuthType", existing.AuthType, incoming.AuthType); // Secrets: presence-only comparison (we never echo the value in the diff). var existingHasSecret = !string.IsNullOrEmpty(existing.AuthConfiguration); var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("AuthConfiguration"); if (existingHasSecret != incomingHasSecret) { changes.Add(new FieldChange("Secrets.AuthConfiguration", existingHasSecret ? "" : null, incomingHasSecret ? "" : null)); } // Methods are name-keyed children. var existingForCompare = existingMethods ?? Array.Empty(); DiffChildren( existingForCompare, incoming.Methods, e => e.Name, i => i.Name, ExternalSystemMethodsEqual, "Methods", changes); return BuildItem("ExternalSystem", incoming.Name, changes); } /// /// Compares an incoming database connection against the existing database connection in the database. /// /// The incoming database connection from the bundle. /// The existing database connection in the database, or null if new. /// An import preview item describing the conflict type and differences. public ImportPreviewItem CompareDatabaseConnection(DatabaseConnectionDto incoming, DatabaseConnectionDefinition? existing) { ArgumentNullException.ThrowIfNull(incoming); if (existing is null) return New("DatabaseConnection", incoming.Name); var changes = new List(); AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries); AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString()); // ConnectionString lives in Secrets only — presence-only comparison. var existingHasSecret = !string.IsNullOrEmpty(existing.ConnectionString); var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("ConnectionString"); if (existingHasSecret != incomingHasSecret) { changes.Add(new FieldChange("Secrets.ConnectionString", existingHasSecret ? "" : null, incomingHasSecret ? "" : null)); } return BuildItem("DatabaseConnection", incoming.Name, changes); } /// /// Compares an incoming notification list against the existing notification list in the database. /// /// The incoming notification list from the bundle. /// The existing notification list in the database, or null if new. /// An import preview item describing the conflict type and differences. public ImportPreviewItem CompareNotificationList(NotificationListDto incoming, NotificationList? existing) { ArgumentNullException.ThrowIfNull(incoming); if (existing is null) return New("NotificationList", incoming.Name); var changes = new List(); AddIfDifferent(changes, "Type", existing.Type.ToString(), incoming.Type.ToString()); DiffChildren( existing.Recipients, incoming.Recipients, e => e.Name, i => i.Name, // A recipient is unchanged only when BOTH contacts match — a phone // number added/changed on an existing recipient is a real diff. (e, i) => e.EmailAddress == i.EmailAddress && e.PhoneNumber == i.PhoneNumber, "Recipients", changes); return BuildItem("NotificationList", incoming.Name, changes); } /// /// Compares an incoming SMTP configuration against the existing SMTP configuration in the database. /// /// The incoming SMTP configuration from the bundle. /// The existing SMTP configuration in the database, or null if new. /// An import preview item describing the conflict type and differences. public ImportPreviewItem CompareSmtpConfiguration(SmtpConfigDto incoming, SmtpConfiguration? existing) { ArgumentNullException.ThrowIfNull(incoming); if (existing is null) return New("SmtpConfiguration", incoming.Host); var changes = new List(); AddIfDifferent(changes, "Port", existing.Port, incoming.Port); AddIfDifferent(changes, "AuthType", existing.AuthType, incoming.AuthType); AddIfDifferent(changes, "FromAddress", existing.FromAddress, incoming.FromAddress); AddIfDifferent(changes, "TlsMode", existing.TlsMode, incoming.TlsMode); AddIfDifferent(changes, "ConnectionTimeoutSeconds", existing.ConnectionTimeoutSeconds, incoming.ConnectionTimeoutSeconds); AddIfDifferent(changes, "MaxConcurrentConnections", existing.MaxConcurrentConnections, incoming.MaxConcurrentConnections); AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries); AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString()); var existingHasSecret = !string.IsNullOrEmpty(existing.Credentials); var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("Credentials"); if (existingHasSecret != incomingHasSecret) { changes.Add(new FieldChange("Secrets.Credentials", existingHasSecret ? "" : null, incomingHasSecret ? "" : null)); } return BuildItem("SmtpConfiguration", incoming.Host, changes); } /// /// Compares an incoming SMS provider configuration against the existing one in /// the database. Mirrors : keyed by /// the natural key AccountSid, with the auth token diffed presence-only /// (it lives in , never compared by value). /// /// The incoming SMS configuration from the bundle. /// The existing SMS configuration in the database, or null if new. /// An import preview item describing the conflict type and differences. public ImportPreviewItem CompareSmsConfiguration(SmsConfigDto incoming, SmsConfiguration? existing) { ArgumentNullException.ThrowIfNull(incoming); if (existing is null) return New("SmsConfiguration", incoming.AccountSid); var changes = new List(); AddIfDifferent(changes, "FromNumber", existing.FromNumber, incoming.FromNumber); AddIfDifferent(changes, "MessagingServiceSid", existing.MessagingServiceSid, incoming.MessagingServiceSid); AddIfDifferent(changes, "ApiBaseUrl", existing.ApiBaseUrl, incoming.ApiBaseUrl); AddIfDifferent(changes, "ConnectionTimeoutSeconds", existing.ConnectionTimeoutSeconds, incoming.ConnectionTimeoutSeconds); AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries); AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString()); var existingHasSecret = !string.IsNullOrEmpty(existing.AuthToken); var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("AuthToken"); if (existingHasSecret != incomingHasSecret) { changes.Add(new FieldChange("Secrets.AuthToken", existingHasSecret ? "" : null, incomingHasSecret ? "" : null)); } return BuildItem("SmsConfiguration", incoming.AccountSid, changes); } // CompareApiKey was removed: inbound API keys are not transported // between environments, so the import preview never diffs keys. /// /// Compares an incoming API method against the existing API method in the database. /// /// The incoming API method from the bundle. /// The existing API method in the database, or null if new. /// An import preview item describing the conflict type and differences. public ImportPreviewItem CompareApiMethod(ApiMethodDto incoming, ApiMethod? existing) { ArgumentNullException.ThrowIfNull(incoming); if (existing is null) return New("ApiMethod", incoming.Name); var changes = new List(); // ApprovedApiKeyIds is not transported and is excluded from the diff. AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions); AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition); AddIfDifferent(changes, "TimeoutSeconds", existing.TimeoutSeconds, incoming.TimeoutSeconds); AddCodeChangeIfDifferent(changes, "Script", existing.Script, incoming.Script); return BuildItem("ApiMethod", incoming.Name, changes); } /// /// Compares an incoming template folder against the existing template folder in the database. /// /// The incoming template folder from the bundle. /// The existing template folder in the database, or null if new. /// A mapping of folder IDs to names for resolving parent folder references. /// An import preview item describing the conflict type and differences. public ImportPreviewItem CompareTemplateFolder(TemplateFolderDto incoming, TemplateFolder? existing, IReadOnlyDictionary folderNameById) { ArgumentNullException.ThrowIfNull(incoming); if (existing is null) return New("TemplateFolder", incoming.Name); var changes = new List(); AddIfDifferent(changes, "SortOrder", existing.SortOrder, incoming.SortOrder); var existingParentName = existing.ParentFolderId is { } pid && folderNameById.TryGetValue(pid, out var n) ? n : null; AddIfDifferent(changes, "ParentName", existingParentName, incoming.ParentName); return BuildItem("TemplateFolder", incoming.Name, changes); } // ---- Site / Connection / Instance ---- /// /// Compares an incoming site against the existing site in the database. /// Identity is the SiteIdentifier; the diff is coarse over the /// display name, description, and the four cluster/gRPC node addresses. /// /// The incoming site from the bundle. /// The existing site in the database, or null if new. /// An import preview item describing the conflict type and differences. public ImportPreviewItem CompareSite(SiteDto incoming, Site? existing) { ArgumentNullException.ThrowIfNull(incoming); if (existing is null) return New("Site", incoming.SiteIdentifier); var changes = new List(); AddIfDifferent(changes, "Name", existing.Name, incoming.Name); AddIfDifferent(changes, "Description", existing.Description, incoming.Description); AddIfDifferent(changes, "NodeAAddress", existing.NodeAAddress, incoming.NodeAAddress); AddIfDifferent(changes, "NodeBAddress", existing.NodeBAddress, incoming.NodeBAddress); AddIfDifferent(changes, "GrpcNodeAAddress", existing.GrpcNodeAAddress, incoming.GrpcNodeAAddress); AddIfDifferent(changes, "GrpcNodeBAddress", existing.GrpcNodeBAddress, incoming.GrpcNodeBAddress); return BuildItem("Site", incoming.SiteIdentifier, changes); } /// /// Compares an incoming site-scoped data connection (the Sites.DataConnection /// entity, NOT the External-System database connection) against the existing one. /// The Primary/Backup protocol configuration lives in the DTO's /// , so it is compared presence-only — the diff /// never echoes the configuration value, mirroring the external-system / DB-connection /// secret handling. /// /// The incoming data connection from the bundle. /// The existing data connection in the database, or null if new. /// An import preview item describing the conflict type and differences. public ImportPreviewItem CompareDataConnection(DataConnectionDto incoming, DataConnection? existing) { ArgumentNullException.ThrowIfNull(incoming); if (existing is null) return New("DataConnection", incoming.Name); var changes = new List(); AddIfDifferent(changes, "Protocol", existing.Protocol, incoming.Protocol); AddIfDifferent(changes, "FailoverRetryCount", existing.FailoverRetryCount, incoming.FailoverRetryCount); // Protocol config rides in Secrets (PrimaryConfiguration / BackupConfiguration). // Presence-only comparison — never echo the config value. AddSecretPresenceChange(changes, "Secrets.PrimaryConfiguration", !string.IsNullOrEmpty(existing.PrimaryConfiguration), HasSecretKey(incoming.Secrets, "PrimaryConfiguration")); AddSecretPresenceChange(changes, "Secrets.BackupConfiguration", !string.IsNullOrEmpty(existing.BackupConfiguration), HasSecretKey(incoming.Secrets, "BackupConfiguration")); return BuildItem("DataConnection", incoming.Name, changes); } /// /// Compares an incoming deployable instance against the existing one. Identity /// is the UniqueName; the diff is coarse over the template/site/area/state /// scalar fields plus a name-keyed child diff over the four override/binding /// collections. /// /// The caller is responsible for passing a hydrated /// (its AttributeOverrides, AlarmOverrides, NativeAlarmSourceOverrides, /// and ConnectionBindings navigation collections eagerly loaded). A /// non-hydrated entity reads as having no children, which would surface every /// incoming child as an addition. /// /// /// The incoming instance from the bundle. /// The existing (hydrated) instance in the database, or null if new. /// The existing instance's template name (the entity carries only a FK), or null if unknown. /// The existing instance's site identifier (the entity carries only a FK), or null if unknown. /// The existing instance's area name (the entity carries only a FK), or null if unknown / unset. /// An import preview item describing the conflict type and differences. public ImportPreviewItem CompareInstance( InstanceDto incoming, Instance? existing, string? existingTemplateName = null, string? existingSiteIdentifier = null, string? existingAreaName = null) { ArgumentNullException.ThrowIfNull(incoming); if (existing is null) return New("Instance", incoming.UniqueName); var changes = new List(); // The entity stores template/site/area as numeric FKs that can't be compared // cross-environment; the caller resolves them to names. When a name wasn't // supplied we skip that scalar rather than emit a spurious "" diff. if (existingTemplateName is not null) { AddIfDifferent(changes, "TemplateName", existingTemplateName, incoming.TemplateName); } if (existingSiteIdentifier is not null) { AddIfDifferent(changes, "SiteIdentifier", existingSiteIdentifier, incoming.SiteIdentifier); } AddIfDifferent(changes, "AreaName", existingAreaName, incoming.AreaName); AddIfDifferent(changes, "State", existing.State.ToString(), incoming.State.ToString()); DiffChildren( existing.AttributeOverrides, incoming.AttributeOverrides, e => e.AttributeName, i => i.AttributeName, (e, i) => e.OverrideValue == i.OverrideValue && e.ElementDataType == i.ElementDataType, "AttributeOverrides", changes); DiffChildren( existing.AlarmOverrides, incoming.AlarmOverrides, e => e.AlarmCanonicalName, i => i.AlarmCanonicalName, (e, i) => e.TriggerConfigurationOverride == i.TriggerConfigurationOverride && e.PriorityLevelOverride == i.PriorityLevelOverride, "AlarmOverrides", changes); DiffChildren( existing.NativeAlarmSourceOverrides, incoming.NativeAlarmSourceOverrides, e => e.SourceCanonicalName, i => i.SourceCanonicalName, (e, i) => e.ConnectionNameOverride == i.ConnectionNameOverride && e.SourceReferenceOverride == i.SourceReferenceOverride && e.ConditionFilterOverride == i.ConditionFilterOverride, "NativeAlarmSourceOverrides", changes); DiffChildren( existing.ConnectionBindings, incoming.ConnectionBindings, e => e.AttributeName, i => i.AttributeName, // ConnectionName resolves to a FK on the entity (DataConnectionId) that // can't be compared cross-environment, so the binding diff compares only // the per-attribute DataSourceReference override. (e, i) => e.DataSourceReferenceOverride == i.DataSourceReferenceOverride, "ConnectionBindings", changes); return BuildItem("Instance", incoming.UniqueName, changes); } // ---- Helpers ---- private static ImportPreviewItem New(string entityType, string name) => new(entityType, name, ExistingVersion: null, IncomingVersion: null, ConflictKind.New, FieldDiffJson: null, BlockerReason: null); private static ImportPreviewItem BuildItem(string entityType, string name, List changes) { if (changes.Count == 0) { return new ImportPreviewItem(entityType, name, null, null, ConflictKind.Identical, FieldDiffJson: null, BlockerReason: null); } var diff = new FieldDiff( Adds: Array.Empty(), Removes: Array.Empty(), Changes: changes); return new ImportPreviewItem(entityType, name, null, null, ConflictKind.Modified, FieldDiffJson: JsonSerializer.Serialize(diff, DiffJsonOptions), BlockerReason: null); } private static void AddIfDifferent(List changes, string field, T existing, T incoming) { if (!Equals(existing, incoming)) { changes.Add(new FieldChange(field, existing?.ToString(), incoming?.ToString())); } } private static void AddCodeChangeIfDifferent(List changes, string field, string? existing, string? incoming) { // Code/large-text fields: when they differ, emit a real per-line Myers diff. // The OldValue/NewValue keep a compact "" summary as a // fallback for renderers that don't consume the structured payload; the // LineDiff carries the +/- hunk lines. Identical code emits no change. var sameNullness = existing is null == incoming is null; var bothPresentAndEqual = sameNullness && (existing is null || string.Equals(existing, incoming, StringComparison.Ordinal)); if (bothPresentAndEqual) return; changes.Add(BuildCodeFieldChange(field, existing, incoming)); } /// /// Builds a for a code/large-text field carrying both /// the compact line-count summary and the structured per-line Myers diff. Only /// ever called for the named code fields (TemplateScript.Code, SharedScript.Code, /// ApiMethod.Script) — never for secret-bearing fields — so the verbatim line /// text emitted here is never sensitive. /// private static FieldChange BuildCodeFieldChange(string field, string? existing, string? incoming) { var oldLines = existing?.Split('\n').Length ?? 0; var newLines = incoming?.Split('\n').Length ?? 0; // LineDiffer caps at maxLines (default 400); the payload's Truncated flag // tells the UI when the hunk list was clipped so the JSON stays bounded. var diff = LineDiffer.Diff(existing, incoming); return new FieldChange(field, $"<{oldLines} lines>", $"<{newLines} lines>", ToPayload(diff)); } private static LineDiffPayload ToPayload(LineDiffResult diff) { var hunks = new List(diff.Lines.Count); foreach (var line in diff.Lines) { hunks.Add(new LineDiffHunk(OpName(line.Op), line.Text, line.OldLineNo, line.NewLineNo)); } return new LineDiffPayload(hunks, diff.Truncated, diff.AddedCount, diff.RemovedCount); } private static string OpName(LineDiffOp op) => op switch { LineDiffOp.Add => "add", LineDiffOp.Remove => "remove", _ => "context", }; /// True if carries the named key. private static bool HasSecretKey(SecretsBlock? secrets, string key) => secrets is not null && secrets.Values.ContainsKey(key); /// /// Records a presence-only secret change (a flip in whether a value is set on /// each side) using the <present> / null marker convention. The /// secret value itself is NEVER read into the diff — only its presence. /// private static void AddSecretPresenceChange(List changes, string field, bool existingHasSecret, bool incomingHasSecret) { if (existingHasSecret != incomingHasSecret) { changes.Add(new FieldChange(field, existingHasSecret ? "" : null, incomingHasSecret ? "" : null)); } } private static void DiffChildren( IEnumerable existing, IEnumerable incoming, Func existingKey, Func incomingKey, Func equal, string childCategory, List changes) { var existingByName = existing.GroupBy(existingKey, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); var incomingByName = incoming.GroupBy(incomingKey, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); var added = incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList(); var removed = existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList(); foreach (var name in added) { changes.Add(new FieldChange($"{childCategory}.{name}", null, "")); } foreach (var name in removed) { changes.Add(new FieldChange($"{childCategory}.{name}", "", null)); } foreach (var (name, e) in existingByName) { if (!incomingByName.TryGetValue(name, out var i)) continue; if (!equal(e, i)) { changes.Add(new FieldChange($"{childCategory}.{name}", "", "")); } } } /// /// Scripts get a finer-grained per-row line-count delta so the preview UI /// can show the operator which scripts changed and roughly how much. /// private static void DiffScriptChildren( IEnumerable existing, IEnumerable incoming, List changes) { var existingByName = existing.GroupBy(s => s.Name, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); var incomingByName = incoming.GroupBy(s => s.Name, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); foreach (var name in incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal)) { var inc = incomingByName[name]; var newLines = inc.Code.Split('\n').Length; changes.Add(new FieldChange($"Scripts.{name}", null, $"")); } foreach (var name in existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal)) { var ex = existingByName[name]; var oldLines = ex.Code.Split('\n').Length; changes.Add(new FieldChange($"Scripts.{name}", $"", null)); } foreach (var (name, ex) in existingByName) { if (!incomingByName.TryGetValue(name, out var inc)) continue; if (!TemplateChildEquality.ScriptsEqual(ex, inc)) { // A script row can diverge in Code and/or its trigger/param metadata. // The structured line diff covers the Code body; when only the // metadata changed the hunk list is all-context (no +/-), which still // tells the operator the row changed. changes.Add(BuildCodeFieldChange($"Scripts.{name}", ex.Code, inc.Code)); } } } private static bool ExternalSystemMethodsEqual(ExternalSystemMethod e, ExternalSystemMethodDto i) => e.HttpMethod == i.HttpMethod && e.Path == i.Path && e.ParameterDefinitions == i.ParameterDefinitions && e.ReturnDefinition == i.ReturnDefinition; private static string? FolderNameOf(Template t) { // Templates carry only a FK to the folder; the EntitySerializer projects // it to a name. The diff doesn't have access to a folder lookup at // CompareTemplate scope, so fall back to "" when only the id is // known. The PreviewAsync caller can pass a hydrated Template (via // GetTemplateWithChildrenAsync) and the folder name typically isn't on // it — this branch is a deliberate best-effort. return t.FolderId is null ? null : $""; } private static string? BaseTemplateNameOf(Template t) { return t.ParentTemplateId is null ? null : $""; } private static string CompositionTargetNameOf(TemplateComposition comp) { return $""; } private sealed record FieldChange( [property: JsonPropertyName("field")] string Field, [property: JsonPropertyName("oldValue")] string? OldValue, [property: JsonPropertyName("newValue")] string? NewValue, // Present only for code / large-text fields. Null (and omitted from // the JSON by the WhenWritingNull policy) for ordinary coarse fields. [property: JsonPropertyName("lineDiff")] LineDiffPayload? LineDiff = null); /// /// Serializable projection of embedded inside a /// code . is in source order and /// may be truncated (see ); / /// always report the full diff totals. The UI (E2) /// renders the +/- view directly from . /// private sealed record LineDiffPayload( [property: JsonPropertyName("hunks")] IReadOnlyList Hunks, [property: JsonPropertyName("truncated")] bool Truncated, [property: JsonPropertyName("addedCount")] int AddedCount, [property: JsonPropertyName("removedCount")] int RemovedCount); /// /// One emitted line of a code line-diff. is the lower-case /// op name (context / add / remove); line numbers are /// 1-based and null where the side does not participate (an add has no /// old line number, a remove has no new line number). /// private sealed record LineDiffHunk( [property: JsonPropertyName("op")] string Op, [property: JsonPropertyName("text")] string Text, [property: JsonPropertyName("oldLineNo")] int? OldLineNo, [property: JsonPropertyName("newLineNo")] int? NewLineNo); private sealed record FieldDiff( [property: JsonPropertyName("adds")] IReadOnlyList Adds, [property: JsonPropertyName("removes")] IReadOnlyList Removes, [property: JsonPropertyName("changes")] IReadOnlyList Changes); }