From 23e64adb75cf6e7a09a6744603b68387ae2118d5 Mon Sep 17 00:00:00 2001 From: jeppesc11 Date: Mon, 3 Nov 2025 20:27:49 +0100 Subject: [PATCH 1/4] Added EnsureAndSetUserFields method to ClientContextExtensions Implements safe user field assignment with automatic user resolution and error handling using ExceptionHandlingScope pattern. --- .../Extensions/ClientContextExtensions.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/lib/PnP.Framework/Extensions/ClientContextExtensions.cs b/src/lib/PnP.Framework/Extensions/ClientContextExtensions.cs index dbaf9aa93..0b1c2874c 100644 --- a/src/lib/PnP.Framework/Extensions/ClientContextExtensions.cs +++ b/src/lib/PnP.Framework/Extensions/ClientContextExtensions.cs @@ -1158,6 +1158,57 @@ public static async Task DeleteSiteAsync(this ClientContext clientContext) return await SiteCollection.DeleteSiteAsync(clientContext); } + /// + /// Safely assigns user email addresses to SharePoint list item user fields with automatic user resolution and error handling + /// + /// The SharePoint client context for executing operations + /// The target list item to update with user field assignments + /// Collection of field-email pairs where Field is the internal field name and Email is the user's email address + /// If true, clears the field when email is null/empty; if false, skips the assignment + /// + /// This method queues operations using ExceptionHandlingScope but does not execute them. + /// The caller must call ctx.ExecuteQueryRetry() after this method to execute the queued operations. + /// Multiple calls can be batched together before executing. + /// + public static void EnsureAndSetUserFields( + this ClientContext ctx, + ListItem item, + IEnumerable<(string Field, string Email)> fieldAssignments, + bool clearIfNullOrWhiteSpace) + { + foreach (var (field, email) in fieldAssignments) + { + if (string.IsNullOrWhiteSpace(email)) + { + if (clearIfNullOrWhiteSpace) + { + item[field] = null; + item.Update(); + } + + continue; + } + + var scope = new ExceptionHandlingScope(ctx); + using (scope.StartScope()) + { + using (scope.StartTry()) + { + item[field] = FieldUserValue.FromUser(email); + item.Update(); + } + + using (scope.StartCatch()) + { + ctx.Web.EnsureUser(email); + item[field] = FieldUserValue.FromUser(email); + item.Update(); + } + } + } + } + + internal static CookieContainer GetAuthenticationCookies(this ClientContext context) { var authCookiesContainer = context.GetContextSettings()?.AuthenticationManager.CookieContainer; From 6845ec206f55f3ef003cae86fd647bcb527d87ad Mon Sep 17 00:00:00 2001 From: jeppesc11 Date: Sun, 23 Nov 2025 14:21:19 +0100 Subject: [PATCH 2/4] feat: Add GetDifferences method for ListItem field change detection --- .../Extensions/ListItemExtensionsTests.cs | 468 ++++++++++++++++++ .../Extensions/ObjectExtensions.cs | 132 +++++ .../Modernization/Entities/FieldChange.cs | 57 +++ .../Extensions/ListItemExtensions.cs | 38 +- 4 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 src/lib/PnP.Framework.Modernization.Test/Extensions/ListItemExtensionsTests.cs create mode 100644 src/lib/PnP.Framework/Modernization/Entities/FieldChange.cs diff --git a/src/lib/PnP.Framework.Modernization.Test/Extensions/ListItemExtensionsTests.cs b/src/lib/PnP.Framework.Modernization.Test/Extensions/ListItemExtensionsTests.cs new file mode 100644 index 000000000..2e110bb6c --- /dev/null +++ b/src/lib/PnP.Framework.Modernization.Test/Extensions/ListItemExtensionsTests.cs @@ -0,0 +1,468 @@ +using Microsoft.SharePoint.Client; +using Microsoft.SharePoint.Client.Taxonomy; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; + +namespace PnP.Framework.Modernization.Tests.Extensions +{ + [TestClass] + public class ListItemExtensionsTests + { + [TestMethod] + public void GetDifferences_WithIdenticalValues_ReturnsEmptyList() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["Title"] = "Test Title", + ["Description"] = "Test Description" + }); + + var newValues = new Dictionary + { + ["Title"] = "Test Title", + ["Description"] = "Test Description" + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(0, differences.Count); + } + + [TestMethod] + public void GetDifferences_WithDifferentValues_ReturnsExpectedChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["Title"] = "Old Title", + ["Description"] = "Old Description" + }); + + var newValues = new Dictionary + { + ["Title"] = "New Title", + ["Description"] = "Old Description" + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("Title", differences[0].FieldInternalName); + Assert.AreEqual("New Title", differences[0].NewValue); + Assert.AreEqual("Old Title", differences[0].CurrentValue); + } + + [TestMethod] + public void GetDifferences_WithEmptyStringTreatAsNull_True_HandlesEmptyStringsAsNull() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["Title"] = "", + ["Description"] = null + }); + + var newValues = new Dictionary + { + ["Title"] = null, + ["Description"] = "" + }; + + // Act + var differences = listItem.GetDifferences(newValues, true); + + // Assert + Assert.AreEqual(0, differences.Count, "Empty strings and nulls should be treated as equal when treatEmptyStringAsNull is true"); + } + + [TestMethod] + public void GetDifferences_WithEmptyStringTreatAsNull_False_HandlesEmptyStringsAsDifferent() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["Title"] = "", + ["Description"] = null + }); + + var newValues = new Dictionary + { + ["Title"] = null, + ["Description"] = "" + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(2, differences.Count, "Empty strings and nulls should be treated as different when treatEmptyStringAsNull is false"); + } + + [TestMethod] + public void GetDifferences_WithNullCurrentValue_ReturnsCorrectChange() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["Title"] = null + }); + + var newValues = new Dictionary + { + ["Title"] = "New Title" + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("Title", differences[0].FieldInternalName); + Assert.AreEqual("New Title", differences[0].NewValue); + Assert.IsNull(differences[0].CurrentValue); + } + + [TestMethod] + public void GetDifferences_WithMissingField_TreatsAsNull() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary()); + + var newValues = new Dictionary + { + ["Title"] = "New Title" + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("Title", differences[0].FieldInternalName); + Assert.AreEqual("New Title", differences[0].NewValue); + Assert.IsNull(differences[0].CurrentValue); + } + + [TestMethod] + public void GetDifferences_WithVariousDataTypes_HandlesCorrectly() + { + // Arrange + var currentDate = DateTime.UtcNow.AddDays(-1); + var newDate = DateTime.UtcNow; + + var listItem = CreateMockListItem(new Dictionary + { + ["Title"] = "Old Title", + ["NumberField"] = 100, + ["DateField"] = currentDate, + ["UserField"] = new FieldUserValue { LookupId = 1}, + ["MultiLookupField"] = new FieldLookupValue[] + { + new FieldLookupValue { LookupId = 1} + }, + ["TaxonomyField"] = new TaxonomyFieldValue { TermGuid = "old-guid", Label = "OldTerm" }, + ["GeoLocationField"] = new FieldGeolocationValue { Latitude = 40.0, Longitude = -70.0, Altitude = 100.0, Measure = 0.0 } + }); + + var newValues = new Dictionary + { + ["Title"] = "New Title", + ["NumberField"] = 123, + ["DateField"] = newDate, + ["UserField"] = new FieldUserValue {LookupId = 5 }, + ["MultiLookupField"] = new FieldLookupValue[] + { + new FieldLookupValue { LookupId = 2 }, + new FieldLookupValue { LookupId = 3 } + }, + ["TaxonomyField"] = new TaxonomyFieldValue { TermGuid = "abcd-efgh-ijkl-mnop", Label = "Term1" }, + ["GeoLocationField"] = new FieldGeolocationValue { Latitude = 47.6097, Longitude = -122.3331, Altitude = 0, Measure = 0 } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(7, differences.Count, "All fields should show differences"); + + // Verify each field change + var titleChange = differences.FirstOrDefault(d => d.FieldInternalName == "Title"); + Assert.IsNotNull(titleChange); + Assert.AreEqual("New Title", titleChange.NewValue); + Assert.AreEqual("Old Title", titleChange.CurrentValue); + + var numberChange = differences.FirstOrDefault(d => d.FieldInternalName == "NumberField"); + Assert.IsNotNull(numberChange); + Assert.AreEqual(123, numberChange.NewValue); + Assert.AreEqual(100, numberChange.CurrentValue); + + var dateChange = differences.FirstOrDefault(d => d.FieldInternalName == "DateField"); + Assert.IsNotNull(dateChange); + Assert.AreEqual(newDate, dateChange.NewValue); + Assert.AreEqual(currentDate, dateChange.CurrentValue); + } + + [TestMethod] + public void GetDifferences_WithIdenticalComplexTypes_ReturnsEmptyList() + { + // Arrange + var userValue = new FieldUserValue {LookupId = 5 }; + var lookupValues = new FieldLookupValue[] + { + new FieldLookupValue { LookupId = 2 }, + new FieldLookupValue { LookupId = 3 } + }; + var taxonomyValue = new TaxonomyFieldValue { TermGuid = "abcd-efgh-ijkl-mnop", Label = "Term1" }; + var geoValue = new FieldGeolocationValue { Latitude = 47.6097, Longitude = -122.3331, Altitude = 0, Measure = 0 }; + + var listItem = CreateMockListItem(new Dictionary + { + ["UserField"] = userValue, + ["MultiLookupField"] = lookupValues, + ["TaxonomyField"] = taxonomyValue, + ["GeoLocationField"] = geoValue + }); + + var newValues = new Dictionary + { + ["UserField"] = new FieldUserValue {LookupId = 5 }, + ["MultiLookupField"] = new FieldLookupValue[] + { + new FieldLookupValue { LookupId = 2 }, + new FieldLookupValue { LookupId = 3 } + }, + ["TaxonomyField"] = new TaxonomyFieldValue { TermGuid = "abcd-efgh-ijkl-mnop", Label = "Term1" }, + ["GeoLocationField"] = new FieldGeolocationValue { Latitude = 47.6097, Longitude = -122.3331, Altitude = 0, Measure = 0 } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(0, differences.Count, "Identical complex types should not show differences"); + } + + [TestMethod] + public void GetDifferences_WithDifferentUserFields_ReturnsCorrectChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["UserField"] = new FieldUserValue {LookupId = 1 } + }); + + var newValues = new Dictionary + { + ["UserField"] = new FieldUserValue {LookupId = 5 } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("UserField", differences[0].FieldInternalName); + + var newUserValue = differences[0].NewValue as FieldUserValue; + var currentUserValue = differences[0].CurrentValue as FieldUserValue; + + Assert.IsNotNull(newUserValue); + Assert.IsNotNull(currentUserValue); + Assert.AreEqual(5, newUserValue.LookupId); + Assert.AreEqual(1, currentUserValue.LookupId); + } + + [TestMethod] + public void GetDifferences_WithDifferentMultiLookupFields_ReturnsCorrectChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["MultiLookupField"] = new FieldLookupValue[] + { + new FieldLookupValue { LookupId = 1 } + } + }); + + var newValues = new Dictionary + { + ["MultiLookupField"] = new FieldLookupValue[] + { + new FieldLookupValue { LookupId = 2 }, + new FieldLookupValue { LookupId = 3 } + } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("MultiLookupField", differences[0].FieldInternalName); + + var newLookupValues = differences[0].NewValue as FieldLookupValue[]; + var currentLookupValues = differences[0].CurrentValue as FieldLookupValue[]; + + Assert.IsNotNull(newLookupValues); + Assert.IsNotNull(currentLookupValues); + Assert.AreEqual(2, newLookupValues.Length); + Assert.AreEqual(1, currentLookupValues.Length); + } + + [TestMethod] + public void GetDifferences_WithDifferentTaxonomyFields_ReturnsCorrectChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["TaxonomyField"] = new TaxonomyFieldValue { TermGuid = "old-guid", Label = "OldTerm" } + }); + + var newValues = new Dictionary + { + ["TaxonomyField"] = new TaxonomyFieldValue { TermGuid = "new-guid", Label = "NewTerm" } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("TaxonomyField", differences[0].FieldInternalName); + + var newTaxValue = differences[0].NewValue as TaxonomyFieldValue; + var currentTaxValue = differences[0].CurrentValue as TaxonomyFieldValue; + + Assert.IsNotNull(newTaxValue); + Assert.IsNotNull(currentTaxValue); + Assert.AreEqual("new-guid", newTaxValue.TermGuid); + Assert.AreEqual("old-guid", currentTaxValue.TermGuid); + } + + [TestMethod] + public void GetDifferences_WithDifferentGeolocationFields_ReturnsCorrectChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["GeoLocationField"] = new FieldGeolocationValue { Latitude = 40.0, Longitude = -70.0, Altitude = 100.0, Measure = 0.0 } + }); + + var newValues = new Dictionary + { + ["GeoLocationField"] = new FieldGeolocationValue { Latitude = 47.6097, Longitude = -122.3331, Altitude = 0, Measure = 0 } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("GeoLocationField", differences[0].FieldInternalName); + + var newGeoValue = differences[0].NewValue as FieldGeolocationValue; + var currentGeoValue = differences[0].CurrentValue as FieldGeolocationValue; + + Assert.IsNotNull(newGeoValue); + Assert.IsNotNull(currentGeoValue); + Assert.AreEqual(47.6097, newGeoValue.Latitude, 0.0001); + Assert.AreEqual(40.0, currentGeoValue.Latitude, 0.0001); + } + + [TestMethod] + public void GetDifferences_WithFieldUrlValue_ReturnsCorrectChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["UrlField"] = new FieldUrlValue { Url = "https://old.com", Description = "Old Site" } + }); + + var newValues = new Dictionary + { + ["UrlField"] = new FieldUrlValue { Url = "https://new.com", Description = "New Site" } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("UrlField", differences[0].FieldInternalName); + + var newUrlValue = differences[0].NewValue as FieldUrlValue; + var currentUrlValue = differences[0].CurrentValue as FieldUrlValue; + + Assert.IsNotNull(newUrlValue); + Assert.IsNotNull(currentUrlValue); + Assert.AreEqual("https://new.com", newUrlValue.Url); + Assert.AreEqual("https://old.com", currentUrlValue.Url); + } + + [TestMethod] + public void GetDifferences_WithStringArrayFields_ReturnsCorrectChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["StringArrayField"] = new string[] { "Option1", "Option2" } + }); + + var newValues = new Dictionary + { + ["StringArrayField"] = new string[] { "Option3", "Option4", "Option5" } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("StringArrayField", differences[0].FieldInternalName); + + var newStringArray = differences[0].NewValue as string[]; + var currentStringArray = differences[0].CurrentValue as string[]; + + Assert.IsNotNull(newStringArray); + Assert.IsNotNull(currentStringArray); + Assert.AreEqual(3, newStringArray.Length); + Assert.AreEqual(2, currentStringArray.Length); + } + + private ListItem CreateMockListItem(Dictionary fieldValues) + { + return new SimpleTestableListItem(fieldValues); + } + } + + #region ListItem Test Object + + public class SimpleTestableListItem : ListItem + { + public SimpleTestableListItem(Dictionary fieldValues) + : base(CreateMockContext(), null) + { + var actualFieldValues = base.FieldValues; + + foreach (var kvp in fieldValues) + { + actualFieldValues[kvp.Key] = kvp.Value; + } + } + + private static ClientRuntimeContext CreateMockContext() + { + return (ClientRuntimeContext)FormatterServices.GetUninitializedObject(typeof(ClientRuntimeContext)); + } + } + + #endregion +} \ No newline at end of file diff --git a/src/lib/PnP.Framework/Extensions/ObjectExtensions.cs b/src/lib/PnP.Framework/Extensions/ObjectExtensions.cs index 306eac01d..5e0afdaf3 100644 --- a/src/lib/PnP.Framework/Extensions/ObjectExtensions.cs +++ b/src/lib/PnP.Framework/Extensions/ObjectExtensions.cs @@ -1,7 +1,12 @@ using System; +using System.Collections; using System.Collections.Generic; +using System.Globalization; +using System.Linq; using System.Linq.Expressions; using System.Reflection; +using Microsoft.SharePoint.Client; +using Microsoft.SharePoint.Client.Taxonomy; namespace PnP.Framework.Extensions { @@ -175,5 +180,132 @@ public static void SetPublicInstancePropertyValue(this object source, string pro System.Reflection.BindingFlags.IgnoreCase)? .SetValue(source, value); } + + /// + /// Compares two values for equality with special handling for SharePoint field types and null/empty string normalization + /// + /// First value to compare + /// Second value to compare + /// Whether to treat empty/whitespace strings as null + /// True if values are considered equal + public static bool ValuesEqual(object a, object b, bool treatEmptyStringAsNull = false) + { + if (ReferenceEquals(a, b)) + { + return true; + } + + if (a is null || b is null) + { + if (treatEmptyStringAsNull) + { + if ((a is null && IsEmptyStringLike(b)) || (b is null && IsEmptyStringLike(a))) + { + return true; + } + } + return false; + } + + a = Normalize(a, treatEmptyStringAsNull); + b = Normalize(b, treatEmptyStringAsNull); + + if (a is IStructuralEquatable seA && b is IStructuralEquatable seB) + { + return StructuralComparisons.StructuralEqualityComparer.Equals(seA, seB); + } + + return Equals(a, b); + } + + /// + /// Checks whether the provided object is an empty or whitespace string + /// + /// The object to check + /// True if the object is a string that is empty or consists only of whitespace + private static bool IsEmptyStringLike(object x) + { + return x is string s && string.IsNullOrWhiteSpace(s); + } + + /// + /// Normalizes a value for comparison + /// + /// The value to normalize + /// Whether to treat empty/whitespace strings as null + /// The normalized value + private static object Normalize(object v, bool treatEmptyStringAsNull) + { + if (v == null) + { + return null; + } + + switch (v) + { + case string s: + return (treatEmptyStringAsNull && string.IsNullOrWhiteSpace(s)) ? null : s; + + case bool b: + return b; + + case byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal: + return Convert.ToDecimal(v, CultureInfo.InvariantCulture); + + case DateTime dt: + var utc = (dt.Kind == DateTimeKind.Utc ? dt : DateTime.SpecifyKind(dt, DateTimeKind.Unspecified)).ToUniversalTime(); + return new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, utc.Second, DateTimeKind.Utc); + + case FieldUserValue u: + return u.LookupId; + + case FieldLookupValue l: + return l.LookupId; + + case IEnumerable multiLookup: + return multiLookup.Select(x => x?.LookupId ?? 0).OrderBy(x => x).ToArray(); + + case TaxonomyFieldValue tx: + return tx.TermGuid?.Trim().ToLowerInvariant(); + + case TaxonomyFieldValueCollection txc: + return txc + .Where(x => x != null && !string.IsNullOrEmpty(x.TermGuid)) + .Select(x => x.TermGuid.Trim().ToLowerInvariant()) + .OrderBy(g => g) + .ToArray(); + + case FieldGeolocationValue geo: + return new ValueTuple( + Math.Round(geo.Latitude, 6), + Math.Round(geo.Longitude, 6), + Math.Round(geo.Altitude, 2), + Math.Round(geo.Measure, 2)); + + case FieldUrlValue url: + return new ValueTuple( + url.Url?.Trim() ?? string.Empty, + url.Description?.Trim() ?? string.Empty); + + case string[] ss: + return ss + .Select(x => treatEmptyStringAsNull && string.IsNullOrWhiteSpace(x) ? null : x) + .OrderBy(x => x, StringComparer.Ordinal) + .ToArray(); + + case IEnumerable enumerable when v is not string: + { + var list = new List(); + foreach (var e in enumerable) + { + list.Add(Normalize(e, treatEmptyStringAsNull)); + } + return list.ToArray(); + } + + default: + return v; + } + } } } diff --git a/src/lib/PnP.Framework/Modernization/Entities/FieldChange.cs b/src/lib/PnP.Framework/Modernization/Entities/FieldChange.cs new file mode 100644 index 000000000..3fb3b5994 --- /dev/null +++ b/src/lib/PnP.Framework/Modernization/Entities/FieldChange.cs @@ -0,0 +1,57 @@ +using System; + +namespace PnP.Framework.Modernization.Entities +{ + /// + /// Represents a change detected in a SharePoint list item field value + /// + [Serializable] + public class FieldChange + { + /// + /// Gets or sets the internal name of the field that has changed + /// + public string FieldInternalName { get; set; } + + /// + /// Gets or sets the new value for the field + /// + public object NewValue { get; set; } + + /// + /// Gets or sets the current value of the field (optional, for context) + /// + public object CurrentValue { get; set; } + + /// + /// Initializes a new instance of the FieldChange class + /// + public FieldChange() + { + } + + /// + /// Initializes a new instance of the FieldChange class + /// + /// Internal name of the field + /// New value for the field + public FieldChange(string fieldInternalName, object newValue) + { + FieldInternalName = fieldInternalName; + NewValue = newValue; + } + + /// + /// Initializes a new instance of the FieldChange class + /// + /// Internal name of the field + /// New value for the field + /// Current value of the field + public FieldChange(string fieldInternalName, object newValue, object currentValue) + { + FieldInternalName = fieldInternalName; + NewValue = newValue; + CurrentValue = currentValue; + } + } +} \ No newline at end of file diff --git a/src/lib/PnP.Framework/Modernization/Extensions/ListItemExtensions.cs b/src/lib/PnP.Framework/Modernization/Extensions/ListItemExtensions.cs index 731a84d00..3df6dcdfd 100644 --- a/src/lib/PnP.Framework/Modernization/Extensions/ListItemExtensions.cs +++ b/src/lib/PnP.Framework/Modernization/Extensions/ListItemExtensions.cs @@ -1,10 +1,12 @@ -using PnP.Framework.Modernization; +using PnP.Framework.Extensions; +using PnP.Framework.Modernization; using PnP.Framework.Modernization.Entities; using PnP.Framework.Modernization.Pages; using PnP.Framework.Modernization.Transform; using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.Serialization; using System.Text.RegularExpressions; namespace Microsoft.SharePoint.Client @@ -194,7 +196,7 @@ public static string PageLayout(this ListItem item) else { return pageLayoutName; - } + } } return ""; @@ -304,6 +306,38 @@ public static bool FieldExists(this ListItem item, string fieldName) { return item.FieldValues.ContainsKey(fieldName); } + + /// + /// Gets the differences between the current field values and the provided new values + /// + /// List item to check + /// Dictionary of field internal names and their new values + /// Whether to treat empty string as null when comparing values + /// List of field changes that need to be applied + public static List GetDifferences(this ListItem item, IDictionary newValues, bool treatEmptyStringAsNull) + { + var diffs = new List(); + + foreach (var kvp in newValues) + { + var name = kvp.Key; + var newVal = kvp.Value; + + object currentVal = null; + if (item.FieldValues != null && item.FieldValues.TryGetValue(name, out var cv)) + { + currentVal = cv; + } + + if (!ObjectExtensions.ValuesEqual(currentVal, newVal, treatEmptyStringAsNull)) + { + diffs.Add(new FieldChange(name, newVal, currentVal)); + } + } + + return diffs; + } + #endregion } From f11db7d5061c223bd469ee72d1b66ab694086599 Mon Sep 17 00:00:00 2001 From: jeppesc11 Date: Sun, 23 Nov 2025 14:30:19 +0100 Subject: [PATCH 3/4] Revert "Merge branch 'pnp:dev' into dev" This reverts commit 66f54c7e00e4346d7ca31c7e3370c505a5a6545c, reversing changes made to 23e64adb75cf6e7a09a6744603b68387ae2118d5. --- build/version.debug.increment | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/version.debug.increment b/build/version.debug.increment index 6dd90d24d..252b382b3 100644 --- a/build/version.debug.increment +++ b/build/version.debug.increment @@ -1 +1 @@ -222 \ No newline at end of file +202 \ No newline at end of file From 1fbff479cc157d58098e5d8fb776d3dc590f89f5 Mon Sep 17 00:00:00 2001 From: jeppesc11 Date: Sun, 23 Nov 2025 14:32:02 +0100 Subject: [PATCH 4/4] Revert "Added EnsureAndSetUserFields method to ClientContextExtensions" This reverts commit 23e64adb75cf6e7a09a6744603b68387ae2118d5. --- .../Extensions/ClientContextExtensions.cs | 51 ------------------- 1 file changed, 51 deletions(-) diff --git a/src/lib/PnP.Framework/Extensions/ClientContextExtensions.cs b/src/lib/PnP.Framework/Extensions/ClientContextExtensions.cs index 0b1c2874c..dbaf9aa93 100644 --- a/src/lib/PnP.Framework/Extensions/ClientContextExtensions.cs +++ b/src/lib/PnP.Framework/Extensions/ClientContextExtensions.cs @@ -1158,57 +1158,6 @@ public static async Task DeleteSiteAsync(this ClientContext clientContext) return await SiteCollection.DeleteSiteAsync(clientContext); } - /// - /// Safely assigns user email addresses to SharePoint list item user fields with automatic user resolution and error handling - /// - /// The SharePoint client context for executing operations - /// The target list item to update with user field assignments - /// Collection of field-email pairs where Field is the internal field name and Email is the user's email address - /// If true, clears the field when email is null/empty; if false, skips the assignment - /// - /// This method queues operations using ExceptionHandlingScope but does not execute them. - /// The caller must call ctx.ExecuteQueryRetry() after this method to execute the queued operations. - /// Multiple calls can be batched together before executing. - /// - public static void EnsureAndSetUserFields( - this ClientContext ctx, - ListItem item, - IEnumerable<(string Field, string Email)> fieldAssignments, - bool clearIfNullOrWhiteSpace) - { - foreach (var (field, email) in fieldAssignments) - { - if (string.IsNullOrWhiteSpace(email)) - { - if (clearIfNullOrWhiteSpace) - { - item[field] = null; - item.Update(); - } - - continue; - } - - var scope = new ExceptionHandlingScope(ctx); - using (scope.StartScope()) - { - using (scope.StartTry()) - { - item[field] = FieldUserValue.FromUser(email); - item.Update(); - } - - using (scope.StartCatch()) - { - ctx.Web.EnsureUser(email); - item[field] = FieldUserValue.FromUser(email); - item.Update(); - } - } - } - } - - internal static CookieContainer GetAuthenticationCookies(this ClientContext context) { var authCookiesContainer = context.GetContextSettings()?.AuthenticationManager.CookieContainer;