Index
FetchXmlIndexDocumentFactory.cs
/*
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License. See License.txt in the project root for license information.
*/
namespace Adxstudio.Xrm.Search.Index
{
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;
using Adxstudio.Xrm.AspNet.Cms;
using Adxstudio.Xrm.Resources;
using Lucene.Net.Docameents;
using Microsoft.Xrm.Client;
using Adxstudio.Xrm.Search.Facets;
using Microsoft.Xrm.Sdk.Client;
using Microsoft.Xrm.Sdk.Metadata;
using Adxstudio.Xrm.Cms;
using Adxstudio.Xrm.Configuration;
using Adxstudio.Xrm.ContentAccess;
using Adxstudio.Xrm.Core.Flighting;
using Microsoft.Xrm.Portal.Configuration;
internal clast FetchXmlIndexDocameentFactory
{
private readonly OrganizationServiceContext _dataContext;
private readonly FetchXml _fetchXml;
private readonly ICrmEnsatyIndex _index;
private readonly IDictionary _metadataCache = new Dictionary();
private readonly string _satleAttributeLogicalName;
private readonly FetchXmlLocaleConfig _localeConfig;
private readonly IContentMapProvider _contentMapProvider;
private readonly List oOBUrlDefinedEnsaties = new List()
{
"adx_blog", "adx_blogpost",
"adx_webpage",
"adx_webfile",
"adx_communityforum", "adx_communityforumthread", "adx_communityforumpost",
"adx_idea", "adx_ideaforum",
"incident",
};
public FetchXmlIndexDocameentFactory(ICrmEnsatyIndex index, FetchXml fetchXml, string satleAttributeLogicalName, FetchXmlLocaleConfig localeConfig)
{
if (index == null)
{
throw new ArgumentNullException("index");
}
if (fetchXml == null)
{
throw new ArgumentNullException("fetchXml");
}
if (satleAttributeLogicalName == null)
{
throw new ArgumentNullException("satleAttributeLogicalName");
}
if (localeConfig == null)
{
throw new ArgumentNullException("localeConfig");
}
_index = index;
_contentMapProvider = AdxstudioCrmConfigurationManager.CreateContentMapProvider();
_fetchXml = fetchXml;
_satleAttributeLogicalName = satleAttributeLogicalName;
_localeConfig = localeConfig;
_dataContext = _index.DataContext;
}
///
/// Gets the docameent.
///
/// The fetch XML result.
///
///
///
public CrmEnsatyIndexDocameent GetDocameent(FetchXmlResult fetchXmlResult)
{
var ensatyMetadata = _dataContext.GetEnsatyMetadata(_fetchXml.LogicalName, _metadataCache);
var attributes = ensatyMetadata.Attributes.ToDictionary(a => a.LogicalName, a => a);
var docameent = new Docameent();
var primaryKey = Guid.Empty;
var languageValueAdded = false;
var lcid = 0;
// Store the ensaty logical name and the logical name of the primary key attribute in the index docameent, for
// easier later retrieval of the ensaty corresponding to this docameent.
docameent.Add(
new Field(_index.LogicalNameFieldName, ensatyMetadata.LogicalName, Field.Store.YES, Field.Index.NOT_astYZED));
docameent.Add(
new Field(
_index.PrimaryKeyLogicalNameFieldName,
ensatyMetadata.PrimaryIdAttribute,
Field.Store.YES,
Field.Index.NOT_astYZED));
try
{
var content = new ContentFieldBuilder();
foreach (var fetchXmlField in fetchXmlResult)
{
// Treat the primary key field in a special way.
if (fetchXmlField.Name == ensatyMetadata.PrimaryIdAttribute)
{
primaryKey = new Guid(fetchXmlField.Value);
docameent.Add(new Field(_index.PrimaryKeyFieldName, primaryKey.ToString(), Field.Store.YES, Field.Index.NOT_astYZED));
docameent.Add(new Field(fetchXmlField.Name, primaryKey.ToString(), Field.Store.YES, Field.Index.NOT_astYZED));
// Adding webroles for the webpage to the index.
if (FeatureCheckHelper.IsFeatureEnabled(FeatureNames.CmsEnabledSearching))
{
if (ensatyMetadata.LogicalName == "adx_webpage")
{
ADXTrace.Instance.TraceInfo(TraceCategory.Monitoring, "CMS is enabled. Adding roles for adx_webpage index");
var ruleNames = CmsIndexHelper.GetWebPageWebRoles(this._contentMapProvider, primaryKey);
this.AddWebRolesToDocameent(docameent, ruleNames);
}
if (ensatyMetadata.LogicalName == "adx_ideaforum")
{
ADXTrace.Instance.TraceInfo(TraceCategory.Monitoring, "CMS is enabled. Adding roles for adx_ideaforum index");
var ruleNames = CmsIndexHelper.GetIdeaForumWebRoles(this._contentMapProvider, primaryKey);
this.AddWebRolesToDocameent(docameent, ruleNames);
}
if (ensatyMetadata.LogicalName == "adx_communityforum")
{
ADXTrace.Instance.TraceInfo(TraceCategory.Monitoring, "CMS is enabled. Adding roles for adx_communityforum index");
var ruleNames = CmsIndexHelper.GetForumsWebRoles(this._contentMapProvider, primaryKey);
this.AddWebRolesToDocameent(docameent, ruleNames);
}
}
continue;
}
if (FeatureCheckHelper.IsFeatureEnabled(FeatureNames.CmsEnabledSearching))
{
if (fetchXmlField.Name == "adx_ideaforumid" && ensatyMetadata.LogicalName == "adx_idea")
{
ADXTrace.Instance.TraceInfo(TraceCategory.Monitoring, "CMS is enabled. Adding roles for adx_idea index");
var ruleNames = CmsIndexHelper.GetIdeaForumWebRoles(
this._contentMapProvider,
new Guid(fetchXmlField.Value));
this.AddWebRolesToDocameent(docameent, ruleNames);
}
// Based off the Parent Web Page get the webroles for each given ensaty
if ((fetchXmlField.Name == "adx_parentpageid" && ensatyMetadata.LogicalName == "adx_blog")
|| (fetchXmlField.Name == "adx_blog_blogpost.adx_parentpageid" && ensatyMetadata.LogicalName == "adx_blogpost")
|| (fetchXmlField.Name == "adx_parentpageid" && ensatyMetadata.LogicalName == "adx_webfile"))
{
ADXTrace.Instance.TraceInfo(TraceCategory.Monitoring, string.Format("CMS is enabled. Adding roles for {0} index", fetchXmlField.Name));
var ruleNames = CmsIndexHelper.GetWebPageWebRoles(this._contentMapProvider, new Guid(fetchXmlField.Value));
this.AddWebRolesToDocameent(docameent, ruleNames);
}
if ((fetchXmlField.Name == "adx_forumid" && ensatyMetadata.LogicalName == "adx_communityforumthread")
|| (fetchXmlField.Name == "adx_communityforumpost_communityforumthread.adx_forumid" && ensatyMetadata.LogicalName == "adx_communityforumpost"))
{
ADXTrace.Instance.TraceInfo(TraceCategory.Monitoring, string.Format("CMS is enabled. Adding roles for {0} index", fetchXmlField.Name));
var ruleNames = CmsIndexHelper.GetForumsWebRoles(
this._contentMapProvider,
new Guid(fetchXmlField.Value));
this.AddWebRolesToDocameent(docameent, ruleNames);
}
if (ensatyMetadata.LogicalName == "annotation" && fetchXmlField.Name == "knowledgearticle.knowledgearticleid")
{
var id = new Guid(fetchXmlField.Value);
docameent.Add(new Field("annotation_knowledgearticleid", id.ToString(), Field.Store.YES, Field.Index.NOT_astYZED));
}
}
// Store the satle of the result in a special field.
if (fetchXmlField.Name == _satleAttributeLogicalName && ensatyMetadata.LogicalName != "annotation")
{
docameent.Add(new Field(_index.satleFieldName, fetchXmlField.Value, Field.Store.YES, Field.Index.astYZED));
}
// Store the language locale code in a separate field.
if (_localeConfig.IsLanguageCodeLogicalName(fetchXmlField.Name))
{
docameent.Add(
new Field(
_index.LanguageLocaleCodeFieldName,
fetchXmlField.Value.ToLowerInvariant(),
Field.Store.YES,
Field.Index.NOT_astYZED));
languageValueAdded = true;
}
// Store the language locale LCID in a separate field.
if (_localeConfig.IsLCIDLogicalName(fetchXmlField.Name) && int.TryParse(fetchXmlField.Value, out lcid))
{
docameent.Add(
new Field(_index.LanguageLocaleLCIDFieldName, fetchXmlField.Value, Field.Store.YES, Field.Index.NOT_astYZED));
}
// Skip metadata parsing for language fields
if (_localeConfig.CanSkipMetadata(fetchXmlField.Name)) continue;
FetchXmlLinkAttribute link;
if (_fetchXml.TryGetLinkAttribute(fetchXmlField, out link))
{
var linkEnsatyMetadata = _dataContext.GetEnsatyMetadata(link.EnsatyLogicalName, _metadataCache);
var linkAttributeMetadata = linkEnsatyMetadata.Attributes.FirstOrDefault(a => a.LogicalName == link.LogicalName);
if (linkAttributeMetadata == null)
{
throw new InvalidOperationException("Unable to retrieve attribute metadata for FetchXML result field {0} for ensaty {1}.".FormatWith(link.LogicalName, linkEnsatyMetadata.LogicalName));
}
var fieldName = fetchXmlResult.Any(f => f.Name == link.LogicalName)
? "{0}.{1}".FormatWith(linkEnsatyMetadata.LogicalName, linkAttributeMetadata.LogicalName)
: link.LogicalName;
//Renaming product identifier field to "astociated.product"
if (FeatureCheckHelper.IsFeatureEnabled(FeatureNames.CmsEnabledSearching)
&& (this._fetchXml.LogicalName == "knowledgearticle" && fieldName == "record2id"
|| this._fetchXml.LogicalName == "annotation" && fieldName == "productid"))
{
fieldName = FixedFacetsConfiguration.ProductFieldFacetName;
}
if (FeatureCheckHelper.IsFeatureEnabled(FeatureNames.CmsEnabledSearching)
&& (this._fetchXml.LogicalName == "knowledgearticle" && (fieldName == "notetext" || fieldName == "filename")))
{
fieldName = "related_" + fieldName;
}
if (fieldName == "related_filename")
{
fetchXmlField.Value = Regex.Replace(fetchXmlField.Value, "[._,-]", " ");
}
if (fieldName == "related_notetext")
{
fetchXmlField.Value = fetchXmlField.Value.Substring(GetNotesFilterPrefix().Length);
}
AddDocameentFields(docameent, fieldName, fetchXmlField, linkAttributeMetadata, content);
}
else
{
AttributeMetadata attributeMetadata;
if (!attributes.TryGetValue(fetchXmlField.Name, out attributeMetadata))
{
throw new InvalidOperationException(
ResourceManager.GetString("Attribute_Metadata_Fetchxml_Retrieve_Exception")
.FormatWith(fetchXmlField.Name, ensatyMetadata.LogicalName));
}
if (fetchXmlField.Name == "filename")
{
fetchXmlField.Value = Regex.Replace(fetchXmlField.Value, "[._,-]", " ");
}
if (fetchXmlField.Name == "notetext")
{
fetchXmlField.Value = fetchXmlField.Value.Substring(GetNotesFilterPrefix().Length);
}
AddDocameentFields(docameent, fetchXmlField.Name, fetchXmlField, attributeMetadata, content);
}
}
if (FeatureCheckHelper.IsFeatureEnabled(FeatureNames.CmsEnabledSearching))
{
// Add the default value for ensaties that are not Knowledge articles for the Product filtering field.
if (ensatyMetadata.LogicalName != "knowledgearticle")
{
docameent.Add(
new Field(
FixedFacetsConfiguration.ProductFieldFacetName,
this._index.ProductAccessNonKnowledgeArticleDefaultValue,
Field.Store.NO,
Field.Index.NOT_astYZED));
docameent.Add(
new Field(FixedFacetsConfiguration.ContentAccessLevel,
"public",
Field.Store.NO,
Field.Index.NOT_astYZED));
}
else
{
// If there aren't any products astociated to the article add the default value so then at query time
// based on the site setting it will add these to the result or not.
var productsDocameent = docameent.GetField(FixedFacetsConfiguration.ProductFieldFacetName);
if (productsDocameent == null)
{
docameent.Add(
new Field(
FixedFacetsConfiguration.ProductFieldFacetName,
this._index.ProductAccessDefaultValue,
Field.Store.NO,
Field.Index.NOT_astYZED));
}
}
}
if (!languageValueAdded)
{
docameent.Add(new Field(_index.LanguageLocaleCodeFieldName, _index.LanguageLocaleCodeDefaultValue, Field.Store.YES, Field.Index.NOT_astYZED));
}
// Add the field for the main, astyzed, search content.
docameent.Add(new Field(_index.ContentFieldName, content.ToString(), _index.StoreContentField ? Field.Store.YES : Field.Store.NO, Field.Index.astYZED));
if (_index.AddScopeField)
{
var scopeField = docameent.GetField(_index.ScopeValueSourceFieldName);
var scopeValue = scopeField == null ? _index.ScopeDefaultValue : scopeField.StringValue;
docameent.Add(new Field(_index.ScopeFieldName, scopeValue, Field.Store.NO, Field.Index.NOT_astYZED));
}
if (FeatureCheckHelper.IsFeatureEnabled(FeatureNames.CmsEnabledSearching))
{
this.AddDefaultWebRoleToAllDocameentsNotUnderCMS(docameent, ensatyMetadata.LogicalName);
this.AddUrlDefinedToDocameent(docameent, ensatyMetadata.LogicalName, fetchXmlResult);
}
var docameentastyzer = lcid > 0
? _index.GetLanguageSpecificastyzer(lcid)
: _index.astyzer;
return new CrmEnsatyIndexDocameent(docameent, docameentastyzer, primaryKey);
}
catch (Exception e)
{
ADXTrace.Instance.TraceError(TraceCategory.Application, string.Format("Error: Exception when trying to create the index docameent. {0}", e));
}
return new CrmEnsatyIndexDocameent(docameent, _index.astyzer, primaryKey);
}
private static void AddDocameentFields(Docameent docameent, string fieldName, FetchXmlResultField fetchXmlField, AttributeMetadata attributeMetadata)
{
Guid id;
// We want to normalize the formatting of Guids from what CRM returns, to the default framework
// formatting, for easier querying later.
if (AttributeTypeEqualsOneOf(attributeMetadata, "customer", "lookup", "uniqueidentifier") && Guid.TryParse(fetchXmlField.Value, out id))
{
docameent.Add(new Field(fieldName, id.ToString(), Field.Store.NO, Field.Index.NOT_astYZED));
return;
}
DateTime dateTimeValue;
// Add additional sub-fields for datetimes, for easier queries based on date ranges.
if (AttributeTypeEqualsOneOf(attributeMetadata, "datetime") && DateTime.TryParse(fetchXmlField.Value, out dateTimeValue))
{
docameent.Add(new Field("{0}.date".FormatWith(fieldName), dateTimeValue.ToString("yyyyMMdd"), Field.Store.NO, Field.Index.NOT_astYZED));
docameent.Add(new Field("{0}.year".FormatWith(fieldName), dateTimeValue.ToString("yyyy"), Field.Store.NO, Field.Index.NOT_astYZED));
docameent.Add(new Field("{0}.month".FormatWith(fieldName), dateTimeValue.ToString("MM"), Field.Store.NO, Field.Index.NOT_astYZED));
docameent.Add(new Field("{0}.day".FormatWith(fieldName), dateTimeValue.ToString("dd"), Field.Store.NO, Field.Index.NOT_astYZED));
}
docameent.Add(new Field(fieldName, fetchXmlField.Value, Field.Store.NO, Field.Index.NOT_astYZED));
}
private static void AddDocameentFields(Docameent docameent, string fieldName, FetchXmlResultField fetchXmlField, AttributeMetadata attributeMetadata, ContentFieldBuilder content)
{
AddDocameentFields(docameent, fieldName, fetchXmlField, attributeMetadata);
// If the field is some kind of text content, append it to the main astyzed search content.
if (AttributeTypeEqualsOneOf(attributeMetadata, "string", "memo"))
{
IEnumerable articleSections;
if (TryGetKbArticleSections(attributeMetadata, fetchXmlField.Value, out articleSections))
{
foreach (var section in articleSections)
{
content.Append(section);
}
}
else
{
content.Append(fetchXmlField.Value);
}
}
}
///
/// Adds the given web roles to docameent.
///
///
/// The docameent.
///
///
/// The web role names.
///
private void AddWebRolesToDocameent(Docameent docameent, IEnumerable roleNames)
{
if (roleNames != null)
{
foreach (var rule in roleNames)
{
ADXTrace.Instance.TraceInfo(TraceCategory.Monitoring, string.Format("Adding rule: {0}", rule));
docameent.Add(
new Field(this._index.WebRoleFieldName, rule, Field.Store.NO, Field.Index.NOT_astYZED));
}
}
}
///
/// Adds the default web role to all docameents not under cms.
///
///
/// The docameent.
///
///
/// The ensaty logical name.
///
private void AddDefaultWebRoleToAllDocameentsNotUnderCMS(Docameent docameent, string ensatyLogicalName)
{
var cmsEnsaties = new[]
{
"adx_blog", "adx_blogpost",
"adx_communityforum", "adx_communityforumthread", "adx_communityforumpost",
"adx_idea", "adx_ideaforum",
"adx_webpage",
"adx_webfile"
};
if (!cmsEnsaties.Contains(ensatyLogicalName))
{
docameent.Add(
new Field(this._index.WebRoleFieldName, this._index.WebRoleDefaultValue, Field.Store.NO, Field.Index.NOT_astYZED));
}
}
private void AddUrlDefinedToDocameent(Docameent docameent, string ensatyLogicalName, FetchXmlResult fetchXmlResult)
{
var isUrlDefined = false;
if (ensatyLogicalName == "adx_blog")
{
var blogPartialUrlFetch = fetchXmlResult.FirstOrDefault(x => x.Name == "adx_partialurl");
var parentPageIdFetch = fetchXmlResult.FirstOrDefault(x => x.Name == "adx_parentpageid");
if (blogPartialUrlFetch == null || parentPageIdFetch == null)
{
return;
}
var blogPartialUrl = blogPartialUrlFetch.Value;
isUrlDefined = CmsIndexHelper.IsWebPageUrlDefined(
this._contentMapProvider,
new Guid(parentPageIdFetch.Value),
blogPartialUrl);
}
if (ensatyLogicalName == "adx_blogpost")
{
var blogPostPartialUrlFetch = fetchXmlResult.FirstOrDefault(x => x.Name == "adx_partialurl");
var blogPartialUrlFetch = fetchXmlResult.FirstOrDefault(x => x.Name == "adx_blog_blogpost.adx_partialurl");
var parentWebPageIdFetch = fetchXmlResult.FirstOrDefault(x => x.Name == "adx_blog_blogpost.adx_parentpageid");
if (blogPartialUrlFetch == null || parentWebPageIdFetch == null)
{
return;
}
var blogPostId = fetchXmlResult.FirstOrDefault(x => x.Name == "adx_blogpostid");
if (blogPostPartialUrlFetch == null && blogPostId == null)
{
return;
}
var blogPostPartialUrl = blogPostPartialUrlFetch == null ? blogPostId.Value : blogPostPartialUrlFetch.Value;
var blogPartialUrl = blogPartialUrlFetch.Value;
var blogPostsCombineUrl = string.Format("{0}/{1}", blogPartialUrl, blogPostPartialUrl);
isUrlDefined = CmsIndexHelper.IsWebPageUrlDefined(
this._contentMapProvider,
new Guid(parentWebPageIdFetch.Value),
blogPostsCombineUrl);
}
if (ensatyLogicalName == "adx_communityforum")
{
var forumIdFetch =
fetchXmlResult.FirstOrDefault(x => x.Name == "adx_communityforumid");
if (forumIdFetch == null)
{
return;
}
isUrlDefined = CmsIndexHelper.IsForumUrlDefined(
this._contentMapProvider,
new Guid(forumIdFetch.Value));
}
if (ensatyLogicalName == "adx_communityforumthread")
{
var forumIdFetch =
fetchXmlResult.FirstOrDefault(x => x.Name == "adx_forumid");
if (forumIdFetch == null)
{
return;
}
isUrlDefined = CmsIndexHelper.IsForumUrlDefined(
this._contentMapProvider,
new Guid(forumIdFetch.Value));
}
if (ensatyLogicalName == "adx_communityforumpost")
{
var forumIdFetch =
fetchXmlResult.FirstOrDefault(x => x.Name == "adx_communityforumpost_communityforumthread.adx_forumid");
if (forumIdFetch == null)
{
return;
}
isUrlDefined = CmsIndexHelper.IsForumUrlDefined(
this._contentMapProvider,
new Guid(forumIdFetch.Value));
}
if (ensatyLogicalName == "adx_idea")
{
var ideaPartialUrlFetch = fetchXmlResult.FirstOrDefault(x => x.Name == "adx_partialurl");
var ideaForumPartialUrlFetch = fetchXmlResult.FirstOrDefault(x => x.Name == "adx_idea_ideaforum.adx_partialurl");
if (ideaPartialUrlFetch == null || ideaForumPartialUrlFetch == null)
{
return;
}
var ideaPartialUrl = ideaPartialUrlFetch.Value;
var ideaForumPartialUrl = ideaForumPartialUrlFetch.Value;
isUrlDefined = !string.IsNullOrEmpty(ideaPartialUrl) && !string.IsNullOrEmpty(ideaForumPartialUrl);
}
if (ensatyLogicalName == "adx_ideaforum")
{
var ideaForumPartialUrlFetch = fetchXmlResult.FirstOrDefault(x => x.Name == "adx_partialurl");
if (ideaForumPartialUrlFetch == null)
{
return;
}
isUrlDefined = !string.IsNullOrEmpty(ideaForumPartialUrlFetch.Value);
}
if (ensatyLogicalName == "incident")
{
isUrlDefined = CmsIndexHelper.IsSiteMakerUrlDefined(this._contentMapProvider, "Case");
}
if (ensatyLogicalName == "adx_webfile")
{
var webfilePartialUrlFetch = fetchXmlResult.FirstOrDefault(x => x.Name == "adx_partialurl");
var webPageIdFetch = fetchXmlResult.FirstOrDefault(x => x.Name == "adx_parentpageid");
if (webfilePartialUrlFetch == null || webPageIdFetch == null)
{
return;
}
isUrlDefined = CmsIndexHelper.IsWebPageUrlDefined(
this._contentMapProvider,
new Guid(webPageIdFetch.Value),
webfilePartialUrlFetch.Value);
}
if (ensatyLogicalName == "adx_webpage")
{
var webpageId = fetchXmlResult.FirstOrDefault(x => x.Name == "adx_webpageid");
if (webpageId == null)
{
return;
}
var primaryId = new Guid(webpageId.Value);
isUrlDefined = CmsIndexHelper.IsWebPageUrlDefined(this._contentMapProvider, primaryId);
}
if (!this.oOBUrlDefinedEnsaties.Contains(ensatyLogicalName))
{
isUrlDefined = true;
}
docameent.Add(
new Field(this._index.IsUrlDefinedFieldName, isUrlDefined.ToString(), Field.Store.NO, Field.Index.NOT_astYZED));
}
private static bool AttributeTypeEqualsOneOf(AttributeMetadata attributeMetadata, params string[] typeNames)
{
if (attributeMetadata == null || attributeMetadata.AttributeType == null)
{
return false;
}
var attributeTypeName = attributeMetadata.AttributeType.Value.ToString();
return typeNames.Any(name => string.Equals(attributeTypeName, name, StringComparison.InvariantCultureIgnoreCase));
}
private static bool TryGetKbArticleSections(AttributeMetadata attributeMetadata, string attributeValue, out IEnumerable sections)
{
sections = null;
if (attributeMetadata == null || attributeMetadata.EnsatyLogicalName != "kbarticle" || attributeMetadata.LogicalName != "articlexml")
{
return false;
}
try
{
var articleXml = XDocameent.Parse(attributeValue);
sections = articleXml.XPathSelectElements("//section").Select(e => e.Value);
return true;
}
catch (XmlException)
{
return false;
}
}
private string GetNotesFilterPrefix()
{
var prefix =
_dataContext.CreateQuery("adx_sitesetting")
.Where(s => s.GetAttributeValue("adx_name") == "KnowledgeManagement/NotesFilter")
.Select(v => v.GetAttributeValue("adx_value"))
.FirstOrDefault();
return prefix ?? string.Empty;
}
}
}