diff --git a/Contentstack.Utils.Tests/LivePreviewTagsTest.cs b/Contentstack.Utils.Tests/LivePreviewTagsTest.cs new file mode 100644 index 0000000..5e5338f --- /dev/null +++ b/Contentstack.Utils.Tests/LivePreviewTagsTest.cs @@ -0,0 +1,596 @@ +using System; +using System.Collections.Generic; +using Xunit; +using Contentstack.Utils.Models; +using Contentstack.Utils.Interfaces; +using Newtonsoft.Json.Linq; +using System.IO; + +namespace Contentstack.Utils.Tests +{ + /// + /// Comprehensive test suite for Live Preview editable tags functionality. + /// Tests match the JavaScript SDK test patterns for complete parity. + /// + public class LivePreviewTagsTest + { + #region Test Data and Helpers + + private static JObject ReadJsonRoot(string fileName) + { + var path = Path.Combine(AppContext.BaseDirectory, "Resources", fileName); + return JObject.Parse(File.ReadAllText(path)); + } + + private Dictionary CreateBasicEntry() + { + return new Dictionary + { + ["_version"] = 10, + ["locale"] = "en-us", + ["uid"] = "entry_uid_1", + ["ACL"] = new Dictionary(), + ["rich_text_editor"] = "

Content with text

", + ["rich_text_editor_multiple"] = new List { "

Multiple content

" } + }; + } + + private Dictionary CreateModularBlockEntry() + { + return new Dictionary + { + ["_version"] = 10, + ["locale"] = "en-us", + ["uid"] = "entry_uid_1", + ["ACL"] = new Dictionary(), + ["modular_blocks"] = new object[] + { + new Dictionary + { + ["rich_text_inmodular"] = new Dictionary + { + ["rich_text_editor"] = "

Modular content 1

", + ["rich_text_editor_multiple"] = new List { "

Modular multiple 1

" }, + ["_metadata"] = new Dictionary { ["uid"] = "metadata_uid_1" } + } + }, + new Dictionary + { + ["global_modular"] = new Dictionary + { + ["rich_text_editor"] = "

Global modular content

", + ["rich_text_editor_multiple"] = new List { "

Global modular multiple

" }, + ["group"] = new Dictionary + { + ["rich_text_editor"] = "

Nested group content

", + ["rich_text_editor_multiple"] = new List { "

Nested group multiple

" } + }, + ["_metadata"] = new Dictionary { ["uid"] = "metadata_uid_2" } + } + } + } + }; + } + + private Dictionary CreateReferenceEntry() + { + return new Dictionary + { + ["_version"] = 10, + ["locale"] = "en-us", + ["uid"] = "entry_uid_1", + ["ACL"] = new Dictionary(), + ["reference"] = new object[] + { + new Dictionary + { + ["uid"] = "entry_uid_11", + ["_content_type_uid"] = "embed_entry", + ["title"] = "Reference Entry Title", + ["rich_text_editor"] = "

Reference content

", + ["rich_text_editor_multiple"] = new List { "

Reference multiple

" } + } + } + }; + } + + private Dictionary CreateVariantEntry() + { + return new Dictionary + { + ["_version"] = 10, + ["locale"] = "en-us", + ["uid"] = "entry_uid_1", + ["ACL"] = new Dictionary(), + ["_applied_variants"] = new Dictionary + { + ["rich_text_editor"] = "variant_1", + ["nested.field"] = "variant_2", + ["modular_blocks.content_from_variant.metadata_uid_2"] = "variant_3" + }, + ["rich_text_editor"] = "

Content with variant

", + ["rich_text_editor_multiple"] = new List { "

Multiple content

" }, + ["nested"] = new Dictionary + { + ["field"] = "nested field content", + ["other_field"] = "other nested content" + } + }; + } + + private EditableEntryMock CreateEditableEntry(Dictionary data) + { + return new EditableEntryMock(data); + } + + /// + /// Mock implementation of EditableEntry for testing purposes. + /// + public class EditableEntryMock : EditableEntry + { + private readonly Dictionary _data; + + public EditableEntryMock(Dictionary data) + { + _data = new Dictionary(data); + } + + public string Uid + { + get => _data.ContainsKey("uid") ? _data["uid"]?.ToString() : null; + set => _data["uid"] = value; + } + + public string ContentTypeUid + { + get => _data.ContainsKey("_content_type_uid") ? _data["_content_type_uid"]?.ToString() : null; + set => _data["_content_type_uid"] = value; + } + + public string Title + { + get => _data.ContainsKey("title") ? _data["title"]?.ToString() : null; + set => _data["title"] = value; + } + + public string Locale + { + get => _data.ContainsKey("locale") ? _data["locale"]?.ToString() : null; + set => _data["locale"] = value; + } + + public object this[string key] + { + get => _data.ContainsKey(key) ? _data[key] : null; + set => _data[key] = value; + } + + public Dictionary GetData() => _data; + + // Add ContainsKey method for extensions + public bool ContainsKey(string key) => _data.ContainsKey(key); + } + + #endregion + + #region Basic Functionality Tests + + [Fact] + public void AddEditableTags_BasicEntry_StringTags_GeneratesCorrectTags() + { + // Arrange + var entry = CreateEditableEntry(CreateBasicEntry()); + + // Act + Utils.addEditableTags(entry, "entry_asset", false); + + // Debug output + var dollarKey = entry["$"]; + Assert.NotNull(dollarKey); // First check if $ key exists + + var tags = (Dictionary)dollarKey; + Assert.NotNull(tags); // Check if tags object exists + Assert.True(tags.Count > 0); // Check if tags has any entries + + // Assert the specific tags exist + Assert.True(tags.ContainsKey("rich_text_editor"), $"Missing rich_text_editor key. Available keys: {string.Join(", ", tags.Keys)}"); + Assert.Equal("data-cslp=entry_asset.entry_uid_1.en-us.rich_text_editor", tags["rich_text_editor"]); + Assert.Equal("data-cslp=entry_asset.entry_uid_1.en-us.rich_text_editor_multiple", tags["rich_text_editor_multiple"]); + } + + [Fact] + public void AddEditableTags_BasicEntry_ObjectTags_GeneratesCorrectTags() + { + // Arrange + var entry = CreateEditableEntry(CreateBasicEntry()); + + // Act + Utils.addEditableTags(entry, "entry_asset", true); + + // Assert + var tags = (Dictionary)entry["$"]; + var richTextTag = (Dictionary)tags["rich_text_editor"]; + var richTextMultipleTag = (Dictionary)tags["rich_text_editor_multiple"]; + + Assert.Equal("entry_asset.entry_uid_1.en-us.rich_text_editor", richTextTag["data-cslp"]); + Assert.Equal("entry_asset.entry_uid_1.en-us.rich_text_editor_multiple", richTextMultipleTag["data-cslp"]); + } + + [Fact] + public void AddEditableTags_NullEntry_DoesNotThrow() + { + // Act & Assert - should not throw + Utils.addEditableTags(null, "entry_asset", false); + } + + [Fact] + public void AddEditableTags_EmptyEntry_DoesNotThrow() + { + // Arrange + var entry = CreateEditableEntry(new Dictionary { ["uid"] = "test", ["locale"] = "en-us" }); + + // Act & Assert - should not throw + Utils.addEditableTags(entry, "entry_asset", false); + + // Should have empty tags object + var tags = (Dictionary)entry["$"]; + Assert.NotNull(tags); + } + + #endregion + + #region Modular Block Tests + + [Fact] + public void AddEditableTags_ModularBlocks_StringTags_GeneratesCorrectNestedTags() + { + // Arrange + var entry = CreateEditableEntry(CreateModularBlockEntry()); + + // Act + Utils.addEditableTags(entry, "entry_multiple_content", false); + + // Assert - Check deeply nested modular block tags + var modularBlocks = (object[])entry.GetData()["modular_blocks"]; + var firstBlock = (Dictionary)modularBlocks[0]; + var richTextInModular = (Dictionary)firstBlock["rich_text_inmodular"]; + var tags = (Dictionary)richTextInModular["$"]; + + Assert.Equal("data-cslp=entry_multiple_content.entry_uid_1.en-us.modular_blocks.0.rich_text_inmodular.rich_text_editor", + tags["rich_text_editor"]); + Assert.Equal("data-cslp=entry_multiple_content.entry_uid_1.en-us.modular_blocks.0.rich_text_inmodular.rich_text_editor_multiple", + tags["rich_text_editor_multiple"]); + } + + [Fact] + public void AddEditableTags_ModularBlocks_ObjectTags_GeneratesCorrectNestedTags() + { + // Arrange + var entry = CreateEditableEntry(CreateModularBlockEntry()); + + // Act + Utils.addEditableTags(entry, "entry_multiple_content", true); + + // Assert - Check object format for nested tags + var modularBlocks = (object[])entry.GetData()["modular_blocks"]; + var secondBlock = (Dictionary)modularBlocks[1]; + var globalModular = (Dictionary)secondBlock["global_modular"]; + var tags = (Dictionary)globalModular["$"]; + var richTextTag = (Dictionary)tags["rich_text_editor"]; + + Assert.Equal("entry_multiple_content.entry_uid_1.en-us.modular_blocks.1.global_modular.rich_text_editor", + richTextTag["data-cslp"]); + } + + #endregion + + #region Reference Entry Tests + + [Fact] + public void AddEditableTags_ReferenceEntry_StringTags_UsesReferenceContentType() + { + // Arrange + var entry = CreateEditableEntry(CreateReferenceEntry()); + + // Act + Utils.addEditableTags(entry, "entry_asset", false); + + // Assert - Reference should use its own content type + var reference = (object[])entry.GetData()["reference"]; + var refEntry = (Dictionary)reference[0]; + var tags = (Dictionary)refEntry["$"]; + + Assert.Equal("data-cslp=embed_entry.entry_uid_11.en-us.rich_text_editor", tags["rich_text_editor"]); + Assert.Equal("data-cslp=embed_entry.entry_uid_11.en-us.rich_text_editor_multiple", tags["rich_text_editor_multiple"]); + } + + [Fact] + public void AddEditableTags_ReferenceEntry_ObjectTags_UsesReferenceContentType() + { + // Arrange + var entry = CreateEditableEntry(CreateReferenceEntry()); + + // Act + Utils.addEditableTags(entry, "entry_asset", true); + + // Assert - Reference should use its own content type in object format + var reference = (object[])entry.GetData()["reference"]; + var refEntry = (Dictionary)reference[0]; + var tags = (Dictionary)refEntry["$"]; + var richTextTag = (Dictionary)tags["rich_text_editor"]; + + Assert.Equal("embed_entry.entry_uid_11.en-us.rich_text_editor", richTextTag["data-cslp"]); + } + + #endregion + + #region Array Processing Tests + + [Fact] + public void AddEditableTags_ArrayWithNullElements_SkipsNullElements() + { + // Arrange + var entryData = new Dictionary + { + ["locale"] = "en-us", + ["uid"] = "uid", + ["items"] = new object[] + { + null, + new Dictionary { ["title"] = "valid item" }, + null + } + }; + var entry = CreateEditableEntry(entryData); + + // Act & Assert - should not throw + Utils.addEditableTags(entry, "content_type", false); + + // Check that valid item got tagged + var items = (object[])entry.GetData()["items"]; + var validItem = (Dictionary)items[1]; + var tags = (Dictionary)validItem["$"]; + + Assert.Equal("data-cslp=content_type.uid.en-us.items.1.title", tags["title"]); + } + + [Fact] + public void AddEditableTags_ArrayElements_GeneratesIndexAndParentTags() + { + // Arrange + var entryData = new Dictionary + { + ["locale"] = "en-us", + ["uid"] = "uid", + ["items"] = new object[] + { + new Dictionary { ["title"] = "item 1" }, + new Dictionary { ["title"] = "item 2" } + } + }; + var entry = CreateEditableEntry(entryData); + + // Act + Utils.addEditableTags(entry, "content_type", false); + + // Assert - Check field__index and field__parent patterns + var tags = (Dictionary)entry["$"]; + + Assert.Contains("items__0", tags.Keys); + Assert.Contains("items__1", tags.Keys); + Assert.Contains("items__parent", tags.Keys); + + Assert.Equal("data-cslp=content_type.uid.en-us.items.0", tags["items__0"]); + Assert.Equal("data-cslp=content_type.uid.en-us.items.1", tags["items__1"]); + Assert.Equal("data-cslp-parent-field=content_type.uid.en-us.items", tags["items__parent"]); + } + + #endregion + + #region Variant Support Tests + + [Fact] + public void AddEditableTags_WithVariants_StringTags_AppliesV2PrefixAndVariantSuffix() + { + // Arrange + var entry = CreateEditableEntry(CreateVariantEntry()); + + // Act + Utils.addEditableTags(entry, "entry_asset", false); + + // Assert - Field with direct variant match should get v2 prefix and variant suffix + var tags = (Dictionary)entry["$"]; + Assert.Equal("data-cslp=v2:entry_asset.entry_uid_1_variant_1.en-us.rich_text_editor", tags["rich_text_editor"]); + + // Field without variant should not have v2 prefix + Assert.Equal("data-cslp=entry_asset.entry_uid_1.en-us.rich_text_editor_multiple", tags["rich_text_editor_multiple"]); + } + + [Fact] + public void AddEditableTags_WithVariants_ObjectTags_AppliesV2PrefixAndVariantSuffix() + { + // Arrange + var entry = CreateEditableEntry(CreateVariantEntry()); + + // Act + Utils.addEditableTags(entry, "entry_asset", true); + + // Assert - Field with direct variant match should get v2 prefix and variant suffix as object + var tags = (Dictionary)entry["$"]; + var richTextTag = (Dictionary)tags["rich_text_editor"]; + var richTextMultipleTag = (Dictionary)tags["rich_text_editor_multiple"]; + + Assert.Equal("v2:entry_asset.entry_uid_1_variant_1.en-us.rich_text_editor", richTextTag["data-cslp"]); + Assert.Equal("entry_asset.entry_uid_1.en-us.rich_text_editor_multiple", richTextMultipleTag["data-cslp"]); + } + + [Fact] + public void AddEditableTags_WithNestedVariants_AppliesCorrectVariants() + { + // Arrange + var entry = CreateEditableEntry(CreateVariantEntry()); + + // Act + Utils.addEditableTags(entry, "entry_asset", false); + + // Assert - Nested field with direct variant match + var nested = (Dictionary)entry.GetData()["nested"]; + var nestedTags = (Dictionary)nested["$"]; + + Assert.Equal("data-cslp=v2:entry_asset.entry_uid_1_variant_2.en-us.nested.field", nestedTags["field"]); + Assert.Equal("data-cslp=entry_asset.entry_uid_1.en-us.nested.other_field", nestedTags["other_field"]); + } + + [Fact] + public void AddEditableTags_EmptyVariants_WorksNormally() + { + // Arrange + var entryData = CreateBasicEntry(); + entryData["_applied_variants"] = new Dictionary(); // Empty variants + var entry = CreateEditableEntry(entryData); + + // Act + Utils.addEditableTags(entry, "entry_asset", false); + + // Assert - Should not have v2 prefix when variants object is empty + var tags = (Dictionary)entry["$"]; + Assert.Equal("data-cslp=entry_asset.entry_uid_1.en-us.rich_text_editor", tags["rich_text_editor"]); + } + + #endregion + + #region Locale and Case Sensitivity Tests + + [Fact] + public void AddEditableTags_UseLowerCaseLocaleTrue_LowercasesLocale() + { + // Arrange + var entry = CreateEditableEntry(CreateBasicEntry()); + var options = new AddEditableTagsOptions { UseLowerCaseLocale = true }; + + // Act + Utils.addEditableTags(entry, "TEST_CONTENT_TYPE", false, "en-US", options); + + // Assert - Both content type and locale should be lowercased + var tags = (Dictionary)entry["$"]; + var tag = (string)tags["rich_text_editor"]; + Assert.Contains("test_content_type.entry_uid_1.en-us.rich_text_editor", tag); + } + + [Fact] + public void AddEditableTags_UseLowerCaseLocaleFalse_PreservesLocaleCase() + { + // Arrange + var entry = CreateEditableEntry(CreateBasicEntry()); + var options = new AddEditableTagsOptions { UseLowerCaseLocale = false }; + + // Act + Utils.addEditableTags(entry, "TEST_CONTENT_TYPE", false, "en-US", options); + + // Assert - Content type lowercased but locale case preserved + var tags = (Dictionary)entry["$"]; + var tag = (string)tags["rich_text_editor"]; + Assert.Contains("test_content_type.entry_uid_1.en-US.rich_text_editor", tag); + } + + [Fact] + public void AddEditableTags_DefaultOptions_LowercasesLocale() + { + // Arrange + var entry = CreateEditableEntry(CreateBasicEntry()); + + // Act + Utils.addEditableTags(entry, "TEST_CONTENT_TYPE", false, "en-US"); + + // Assert - Default behavior should lowercase locale + var tags = (Dictionary)entry["$"]; + var tag = (string)tags["rich_text_editor"]; + Assert.Contains("test_content_type.entry_uid_1.en-us.rich_text_editor", tag); + } + + #endregion + + #region Alias Method Tests + + [Fact] + public void AddTags_Alias_WorksCorrectly() + { + // Arrange + var entry = CreateEditableEntry(CreateBasicEntry()); + + // Act + Utils.addTags(entry, "entry_asset", false); + + // Assert - Should work identically to addEditableTags + var tags = (Dictionary)entry["$"]; + Assert.Equal("data-cslp=entry_asset.entry_uid_1.en-us.rich_text_editor", tags["rich_text_editor"]); + } + + #endregion + + #region Error Handling and Edge Cases + + [Fact] + public void AddEditableTags_NullFieldValue_DoesNotThrow() + { + // Arrange + var entryData = new Dictionary + { + ["uid"] = "entry_uid_null", + ["locale"] = "en-us", + ["title"] = "Valid title", + ["description"] = null + }; + var entry = CreateEditableEntry(entryData); + + // Act & Assert - should not throw + Utils.addEditableTags(entry, "content_type", false); + + var tags = (Dictionary)entry["$"]; + Assert.Equal("data-cslp=content_type.entry_uid_null.en-us.title", tags["title"]); + } + + [Fact] + public void AddEditableTags_ComplexNestedStructure_HandlesGracefully() + { + // Arrange + var entryData = new Dictionary + { + ["locale"] = "en-us", + ["uid"] = "uid", + ["blocks"] = new object[] + { + new Dictionary + { + ["hero"] = new Dictionary + { + ["title"] = "Hero title", + ["items"] = new object[] { null, new Dictionary { ["name"] = "Item name" }, null } + } + }, + null, + new Dictionary + { + ["content"] = "Content text" + } + } + }; + var entry = CreateEditableEntry(entryData); + + // Act & Assert - should not throw + Utils.addEditableTags(entry, "content_type", true); + + // Check that valid nested content got tagged + var blocks = (object[])entry.GetData()["blocks"]; + var firstBlock = (Dictionary)blocks[0]; + var hero = (Dictionary)firstBlock["hero"]; + var heroTags = (Dictionary)hero["$"]; + var titleTag = (Dictionary)heroTags["title"]; + + Assert.Equal("content_type.uid.en-us.blocks.0.hero.title", titleTag["data-cslp"]); + } + + #endregion + } + +} \ No newline at end of file diff --git a/Contentstack.Utils/Extensions/EditableEntryExtension.cs b/Contentstack.Utils/Extensions/EditableEntryExtension.cs new file mode 100644 index 0000000..8854b12 --- /dev/null +++ b/Contentstack.Utils/Extensions/EditableEntryExtension.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using Contentstack.Utils.Interfaces; + +namespace Contentstack.Utils.Extensions +{ + /// + /// Extension methods for EditableEntry to support Live Preview functionality. + /// + public static class EditableEntryExtension + { + /// + /// Checks if the EditableEntry contains a specific key. + /// + /// The EditableEntry to check + /// The key to check for + /// True if the key exists and is accessible, false otherwise + public static bool ContainsKey(this EditableEntry entry, string key) + { + if (entry == null || string.IsNullOrEmpty(key)) + return false; + + try + { + // Try to access the key - if it doesn't exist, this will return null + // but won't throw for most implementations + var value = entry[key]; + return true; + } + catch + { + // Key doesn't exist or access failed + return false; + } + } + + /// + /// Safely gets a value from EditableEntry, returning null if key doesn't exist. + /// + /// The EditableEntry to get from + /// The key to get + /// The value if key exists, null otherwise + public static object SafeGet(this EditableEntry entry, string key) + { + if (entry == null || string.IsNullOrEmpty(key)) + return null; + + try + { + return entry[key]; + } + catch + { + return null; + } + } + + /// + /// Safely sets a value in EditableEntry, ignoring errors. + /// + /// The EditableEntry to set in + /// The key to set + /// The value to set + public static void SafeSet(this EditableEntry entry, string key, object value) + { + if (entry == null || string.IsNullOrEmpty(key)) + return; + + try + { + entry[key] = value; + } + catch + { + // Ignore set failures + } + } + } +} \ No newline at end of file diff --git a/Contentstack.Utils/Models/LivePreviewOptions.cs b/Contentstack.Utils/Models/LivePreviewOptions.cs new file mode 100644 index 0000000..648af50 --- /dev/null +++ b/Contentstack.Utils/Models/LivePreviewOptions.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace Contentstack.Utils.Models +{ + /// + /// Options for addEditableTags method to control Live Preview tag generation behavior. + /// + public class AddEditableTagsOptions + { + /// + /// Whether to convert the locale to lowercase in generated tags. Defaults to true. + /// + public bool UseLowerCaseLocale { get; set; } = true; + } + + /// + /// Container for applied variant information used during Live Preview tag generation. + /// + public class AppliedVariants + { + /// + /// Dictionary mapping field paths to variant identifiers. + /// + public Dictionary _applied_variants { get; set; } + + /// + /// Whether variant processing should be applied based on the presence of applied variants. + /// + public bool shouldApplyVariant { get; set; } + + /// + /// The current field path being processed, used for variant inheritance lookups. + /// + public string metaKey { get; set; } = ""; + + public AppliedVariants() + { + _applied_variants = new Dictionary(); + shouldApplyVariant = false; + } + + public AppliedVariants(Dictionary appliedVariants, string metaKey = "") + { + _applied_variants = appliedVariants ?? new Dictionary(); + shouldApplyVariant = _applied_variants != null && _applied_variants.Count > 0; + this.metaKey = metaKey; + } + } +} \ No newline at end of file diff --git a/Contentstack.Utils/Utils.cs b/Contentstack.Utils/Utils.cs index 4e51c19..090e81d 100644 --- a/Contentstack.Utils/Utils.cs +++ b/Contentstack.Utils/Utils.cs @@ -1,9 +1,10 @@ +using System; using System.Collections.Generic; +using System.Linq; using Contentstack.Utils.Models; using HtmlAgilityPack; using Contentstack.Utils.Extensions; using Contentstack.Utils.Interfaces; -using System; using Contentstack.Utils.Enums; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -200,69 +201,482 @@ private static IEmbeddedObject findEmbedded(Metadata metadata, List + /// Adds Live Preview editable tags to an entry for CMS editing capability. + /// This is the main public API method with enhanced options support. + /// + /// The entry to add tags to + /// Content type UID (will be lowercased) + /// Whether to return tags as objects or strings + /// Locale for the entry (default: "en-us") + /// Options controlling tag generation behavior + public static void addEditableTags(EditableEntry entry, string contentTypeUid, bool tagsAsObject, string locale = "en-us", AddEditableTagsOptions options = null) { - if (entry != null) - entry["$"] = GetTag(entry, $"{contentTypeUid}.{entry.Uid}.{locale}", tagsAsObject, locale); + if (entry == null) return; + + // Apply default options if not provided + options = options ?? new AddEditableTagsOptions(); + + // Normalize inputs according to JavaScript SDK behavior + contentTypeUid = contentTypeUid?.ToLowerInvariant() ?? ""; + locale = options.UseLowerCaseLocale ? (locale?.ToLowerInvariant() ?? "en-us") : (locale ?? "en-us"); + + // Extract applied variants from entry + var appliedVariants = ExtractAppliedVariants(entry); + + // Generate tags and assign to entry + entry["$"] = GetTag(entry, $"{contentTypeUid}.{entry.Uid}.{locale}", tagsAsObject, locale, appliedVariants); } - private static Dictionary GetTag(object content, string prefix, bool tagsAsObject, string locale) + + /// + /// Alias for addEditableTags to match JavaScript SDK naming. + /// + public static void addTags(EditableEntry entry, string contentTypeUid, bool tagsAsObject, string locale = "en-us", AddEditableTagsOptions options = null) { + addEditableTags(entry, contentTypeUid, tagsAsObject, locale, options); + } + + /// + /// Enhanced GetTag method with comprehensive variant support and proper null handling. + /// + private static Dictionary GetTag(object content, string prefix, bool tagsAsObject, string locale, AppliedVariants appliedVariants) + { + // Null safety - return empty tags if content is null or undefined + if (content == null) return new Dictionary(); + var tags = new Dictionary(); - foreach (var property in (Dictionary)content) + + // Handle Dictionary directly + if (content is Dictionary contentDict) { - var key = property.Key; - var value = property.Value; + foreach (var property in contentDict) + { + ProcessContentProperty(property.Key, property.Value, prefix, tagsAsObject, locale, appliedVariants, tags); + } + } + // Handle EditableEntry interface + else if (content is EditableEntry editableEntry) + { + // For EditableEntry, we need to get the underlying data + // Use reflection or casting to get the data + try + { + // Try to get the underlying dictionary if it's our mock class + var getDataMethod = content.GetType().GetMethod("GetData"); + if (getDataMethod != null) + { + var data = (Dictionary)getDataMethod.Invoke(content, null); + foreach (var property in data) + { + ProcessContentProperty(property.Key, property.Value, prefix, tagsAsObject, locale, appliedVariants, tags); + } + } + else + { + // Fallback: try to cast to dictionary + var dict = (Dictionary)content; + foreach (var property in dict) + { + ProcessContentProperty(property.Key, property.Value, prefix, tagsAsObject, locale, appliedVariants, tags); + } + } + } + catch + { + // If all else fails, return empty tags + return tags; + } + } - if (key == "$") - continue; - - switch (value) + return tags; + } + + /// + /// Processes a single content property for tag generation. + /// + private static void ProcessContentProperty(string key, object value, string prefix, bool tagsAsObject, + string locale, AppliedVariants appliedVariants, Dictionary tags) + { + // Skip the $ key to avoid recursive processing + if (key == "$") return; + + // Extract metadata UID for variant path building + string metaUID = ExtractMetadataUid(value); + + // Build updated meta key for variant processing + string updatedMetakey = BuildUpdatedMetaKey(appliedVariants, key, metaUID); + + // Process based on value type + if (IsArrayType(value)) + { + ProcessArrayField(value, key, prefix, tagsAsObject, locale, appliedVariants, updatedMetakey, tags); + } + else if (value != null) + { + ProcessObjectField(value, key, prefix, tagsAsObject, locale, appliedVariants, updatedMetakey, tags); + } + + // Always emit a tag for the field itself (even if value is null) + var fieldPath = $"{prefix}.{key}"; + var fieldVariants = new AppliedVariants(appliedVariants._applied_variants, updatedMetakey); + tags[key] = GetTagsValue(ApplyVariantToDataValue(fieldPath, fieldVariants), tagsAsObject); + } + + /// + /// Legacy GetTag overload for backward compatibility. + /// + private static Dictionary GetTag(object content, string prefix, bool tagsAsObject, string locale) + { + var emptyVariants = new AppliedVariants(); + return GetTag(content, prefix, tagsAsObject, locale, emptyVariants); + } + + /// + /// Extracts applied variants from an entry, checking both _applied_variants and system.applied_variants. + /// + private static AppliedVariants ExtractAppliedVariants(EditableEntry entry) + { + Dictionary variants = null; + + // Try to get _applied_variants first (direct property) + if (entry.ContainsKey("_applied_variants") && entry["_applied_variants"] != null) + { + variants = ConvertToStringDictionary(entry["_applied_variants"]); + } + // Fallback to system.applied_variants + else if (entry.ContainsKey("system") && entry["system"] != null) + { + try { - case object obj when obj is object[] array: - for (int index = 0; index < array.Length; index++) + var system = (Dictionary)entry["system"]; + if (system.ContainsKey("applied_variants") && system["applied_variants"] != null) + { + variants = ConvertToStringDictionary(system["applied_variants"]); + } + } + catch { /* Ignore conversion errors */ } + } + + return new AppliedVariants(variants); + } + + /// + /// Safely converts an object to a string dictionary for variant processing. + /// + private static Dictionary ConvertToStringDictionary(object obj) + { + try + { + if (obj is Dictionary dict) + { + var result = new Dictionary(); + foreach (var kvp in dict) + { + if (kvp.Value != null) { - object objValue = array[index]; - string childKey = $"{key}__{index}"; - string parentKey = $"{key}__parent"; + result[kvp.Key] = kvp.Value.ToString(); + } + } + return result; + } + else if (obj is Dictionary strDict) + { + return new Dictionary(strDict); + } + } + catch { /* Ignore conversion errors */ } - tags[childKey] = GetTagsValue($"{prefix}.{key}.{index}", tagsAsObject); - tags[parentKey] = GetParentTagsValue($"{prefix}.{key}", tagsAsObject); + return new Dictionary(); + } - if (objValue != null && - objValue.GetType().GetProperty("_content_type_uid") != null && - objValue.GetType().GetProperty("Uid") != null) - { - var typedObj = (EditableEntry)objValue; - string locale_ = Convert.ToString(typedObj.GetType().GetProperty("locale").GetValue(typedObj)); - string ctUid = Convert.ToString(typedObj.GetType().GetProperty("_content_type_uid").GetValue(typedObj)); - string uid = Convert.ToString(typedObj.GetType().GetProperty("uid").GetValue(typedObj)); - string localeStr = ""; - if (locale_ != null) - { - localeStr = locale_; - } else - { - localeStr = locale; - } - typedObj["$"] = GetTag(typedObj, $"{ctUid}.{uid}.{localeStr}", tagsAsObject, locale); - } - else if (value is object) + /// + /// Checks if a value is an array type that needs array processing. + /// + private static bool IsArrayType(object value) + { + return value is object[] || + value is List || + value is IEnumerable; + } + + /// + /// Extracts metadata UID from a value object for variant path building. + /// + private static string ExtractMetadataUid(object value) + { + try + { + if (value is Dictionary dict && + dict.ContainsKey("_metadata") && + dict["_metadata"] is Dictionary metadata && + metadata.ContainsKey("uid")) + { + return metadata["uid"]?.ToString(); + } + } + catch { /* Ignore extraction errors */ } + + return null; + } + + /// + /// Builds the updated meta key for variant processing, including metadata UID when applicable. + /// + private static string BuildUpdatedMetaKey(AppliedVariants appliedVariants, string key, string metaUID) + { + var metaKeyPrefix = string.IsNullOrEmpty(appliedVariants.metaKey) ? "" : appliedVariants.metaKey + "."; + var baseMetaKey = metaKeyPrefix + key; + + // Append metadata UID if variants are applied and metaUID exists + if (appliedVariants.shouldApplyVariant && !string.IsNullOrEmpty(metaUID) && !string.IsNullOrEmpty(baseMetaKey)) + { + return baseMetaKey + "." + metaUID; + } + + return baseMetaKey; + } + + /// + /// Processes array fields with proper null handling, reference detection, and variant support. + /// + private static void ProcessArrayField(object value, string key, string prefix, bool tagsAsObject, + string locale, AppliedVariants appliedVariants, string updatedMetakey, Dictionary tags) + { + // Convert to object array for processing + object[] array; + try + { + if (value is object[] objArray) + array = objArray; + else if (value is List list) + array = list.ToArray(); + else + return; // Cannot process this array type + } + catch + { + return; // Conversion failed + } + + // Process each array element + for (int index = 0; index < array.Length; index++) + { + object objValue = array[index]; + + // Skip null and undefined elements (matching JavaScript SDK behavior) + if (objValue == null) continue; + + string childKey = $"{key}__{index}"; + string parentKey = $"{key}__parent"; + + // Generate field__index and field__parent tags + var indexPath = $"{prefix}.{key}.{index}"; + var parentPath = $"{prefix}.{key}"; + + var indexVariants = new AppliedVariants(appliedVariants._applied_variants, updatedMetakey); + var parentVariants = new AppliedVariants(appliedVariants._applied_variants, appliedVariants.metaKey + (string.IsNullOrEmpty(appliedVariants.metaKey) ? "" : ".") + key); + + tags[childKey] = GetTagsValue(ApplyVariantToDataValue(indexPath, indexVariants), tagsAsObject); + tags[parentKey] = GetParentTagsValue(ApplyVariantToDataValue(parentPath, parentVariants), tagsAsObject); + + // Handle reference entries vs regular objects + if (IsReferenceEntry(objValue)) + { + ProcessReferenceEntry(objValue, tagsAsObject, locale); + } + else if (objValue is Dictionary) + { + var elementVariants = new AppliedVariants(appliedVariants._applied_variants, updatedMetakey); + ((Dictionary)objValue)["$"] = GetTag(objValue, indexPath, tagsAsObject, locale, elementVariants); + } + } + } + + /// + /// Processes object fields with variant support. + /// + private static void ProcessObjectField(object value, string key, string prefix, bool tagsAsObject, + string locale, AppliedVariants appliedVariants, string updatedMetakey, Dictionary tags) + { + try + { + if (value is Dictionary dict) + { + var fieldVariants = new AppliedVariants(appliedVariants._applied_variants, updatedMetakey); + dict["$"] = GetTag(value, $"{prefix}.{key}", tagsAsObject, locale, fieldVariants); + } + } + catch { /* Ignore processing errors for malformed objects */ } + } + + /// + /// Checks if an object is a reference entry (has both _content_type_uid and uid properties). + /// + private static bool IsReferenceEntry(object obj) + { + try + { + if (obj is Dictionary dict) + { + return dict.ContainsKey("_content_type_uid") && + dict.ContainsKey("uid") && + dict["_content_type_uid"] != null && + dict["uid"] != null; + } + } + catch { } + + return false; + } + + /// + /// Processes reference entries with independent variant handling. + /// + private static void ProcessReferenceEntry(object referenceObj, bool tagsAsObject, string parentLocale) + { + try + { + var refDict = (Dictionary)referenceObj; + + // Extract reference properties + string refContentTypeUid = refDict["_content_type_uid"]?.ToString(); + string refUid = refDict["uid"]?.ToString(); + string refLocale = refDict.ContainsKey("locale") ? refDict["locale"]?.ToString() : null; + + if (string.IsNullOrEmpty(refContentTypeUid) || string.IsNullOrEmpty(refUid)) + return; + + // Use reference locale or fallback to parent locale + string effectiveLocale = !string.IsNullOrEmpty(refLocale) ? refLocale : parentLocale; + + // Extract reference-specific variants (do not inherit parent variants) + var refVariants = ExtractAppliedVariantsFromObject(refDict); + + // Generate tags for reference using its own content type, UID, and locale + string refPrefix = $"{refContentTypeUid.ToLowerInvariant()}.{refUid}.{effectiveLocale.ToLowerInvariant()}"; + refDict["$"] = GetTag(referenceObj, refPrefix, tagsAsObject, effectiveLocale, refVariants); + } + catch { /* Ignore processing errors for malformed references */ } + } + + /// + /// Extracts applied variants from a generic object (used for reference entries). + /// + private static AppliedVariants ExtractAppliedVariantsFromObject(Dictionary obj) + { + Dictionary variants = null; + + // Try _applied_variants first + if (obj.ContainsKey("_applied_variants") && obj["_applied_variants"] != null) + { + variants = ConvertToStringDictionary(obj["_applied_variants"]); + } + // Fallback to system.applied_variants + else if (obj.ContainsKey("system") && obj["system"] is Dictionary system) + { + if (system.ContainsKey("applied_variants") && system["applied_variants"] != null) + { + variants = ConvertToStringDictionary(system["applied_variants"]); + } + } + + return new AppliedVariants(variants); + } + + /// + /// Applies variant processing to a data value, adding v2: prefix and variant suffix when applicable. + /// + private static string ApplyVariantToDataValue(string dataValue, AppliedVariants appliedVariants) + { + if (!appliedVariants.shouldApplyVariant || appliedVariants._applied_variants == null) + { + return dataValue; + } + + string variant = null; + + // Check for direct field match + if (appliedVariants._applied_variants.ContainsKey(appliedVariants.metaKey)) + { + variant = appliedVariants._applied_variants[appliedVariants.metaKey]; + } + else + { + // Find parent variantised path + string parentPath = GetParentVariantisedPath(appliedVariants); + if (!string.IsNullOrEmpty(parentPath)) + { + variant = appliedVariants._applied_variants[parentPath]; + } + } + + if (string.IsNullOrEmpty(variant)) + { + return dataValue; + } + + // Apply v2: prefix and variant suffix to UID segment + try + { + var segments = ("v2:" + dataValue).Split('.'); + if (segments.Length >= 2) + { + // Modify the UID segment (index 1 after v2: prefix) + segments[1] = segments[1] + "_" + variant; + return string.Join(".", segments); + } + } + catch { } + + return dataValue; + } + + /// + /// Finds the longest matching parent path for variant inheritance. + /// + private static string GetParentVariantisedPath(AppliedVariants appliedVariants) + { + try + { + if (appliedVariants._applied_variants == null || string.IsNullOrEmpty(appliedVariants.metaKey)) + { + return ""; + } + + var childPathFragments = appliedVariants.metaKey.Split('.'); + + // Sort keys by length descending for longest match preference + var sortedKeys = new List(appliedVariants._applied_variants.Keys); + sortedKeys.Sort((a, b) => b.Length.CompareTo(a.Length)); + + foreach (var path in sortedKeys) + { + var parentFragments = path.Split('.'); + + // Check if this path is a parent of the current meta key + if (parentFragments.Length <= childPathFragments.Length) + { + bool isParent = true; + for (int i = 0; i < parentFragments.Length; i++) + { + if (parentFragments[i] != childPathFragments[i]) { - ((EditableEntry)value)["$"] = GetTag(value, $"{prefix}.{key}.{index}", tagsAsObject, locale); + isParent = false; + break; } } - tags[key] = GetTagsValue($"{prefix}.{key}", tagsAsObject); - break; - case object obj when obj != null: - if (value != null) + + if (isParent) { - ((EditableEntry)value)["$"] = GetTag(value, $"{prefix}.{key}", tagsAsObject, locale); + return path; } - break; + } } } - return tags; + catch { } + + return ""; } private static object GetTagsValue(string dataValue, bool tagsAsObject)