Migrate page SKU data into Xperience
In Kentico Xperience 13 (KX13) and older versions, product data is split between content type-specific fields and the SKU object type. For example, a coffee product might have fields like CoffeeVariety, CoffeeIsDecaf, while a related SKU holds data like SKUName, SKUDescription, and SKUPrice.
In this technical deep dive, we’ll migrate SKU and page type data for Coffee products from the KX13 Dancing Goat Core sample site to Xperience by Kentico (XbyK) using custom class mappings.
The result is a practical two-phase approach using the Kentico Migration Tool:
- First, prepare reusable field schemas and supporting product content types.
- Then, remodel
DancingGoatCore.Coffeedata into reusable content with variant and media relationships.
Before you start
To follow this guide, you need:
- Experience with class mappings that use reusable field schemas, such as the example in our Remodel page types as reusable field schemas guide.
- Familiarity with C# and .NET Core.
- Basic understanding of KX13 E-commerce and XbyK Commerce.
- A running KX13 source instance with the Dancing Goat Core sample site, upgraded to Refresh 5 or later.
- A local copy of the Kentico Migration Tool repository.
- A target XbyK instance of a migration-compatible version.
Consider reading about the TrainingGuides product catalogue implementation to familiarize yourself with the content modeling approaches in this example.
We recommend using the three-repository approach to manage your migration.
If you need a more general introduction to the upgrade process, consider following the upgrade walkthrough first.
Understand the migration target model
In the KX13 Dancing Goat, product information for Coffee pages spans page fields, SKU data, SKU variants, and metafile-based product images.
In this guide, the target model in XbyK is:
DancingGoatCore.Coffeemigrated as reusable content.- Shared product data applied through reusable field schemas. (These schemas follow the content model from our product catalogue implementation.)
- Variant SKUs represented as reusable
CoffeeVariantcontent items, referenced from product fields.- The
CoffeeVarianttype contains information for stock, shipping, and price, as parent coffee products cannot be directly sold.
- The
- SKU image metafiles migrated to XbyK media file content items and referenced from product fields.
Lay the groundwork for product migration
When we migrate Coffee products to Xperience by Kentico, the migration will create the type we define in our class mapping, corresponding to the coffee page type in KX13. However, the variants associated with those coffee SKUs in KX13 don’t have pages, so the class mapping can’t create a type for them as easily.
We need to define a content type to represent coffee variants in the XbyK content model, along with reusable field schemas that product types will use.
There are four primary ways to make sure schemas and content types exist in a target XbyK instance:
- Log into the admin UI of the target XbyK project and manually create the schemas and content types.
- This may be tedious if you need to define several types, as you must manually configure each property.
- Enable the Management MCP server and prompt your AI assistant to define the schemas and content types for you.
- This saves time compared to manually defining each type’s fields and their attributes.
- Add custom IPipelineBehavior implementations to ensure that the migration tool creates the schemas and content types.
- This is a fairly complex customization, ideal for large projects with many pages and types. Use this approach for high-volume projects where many types are affected by the migration.
- Create a separate class mapping that defines the schemas and content types, and run a first pass of the migration to create them before migrating products afterward.
- This is simpler than creating a pipeline behavior, but a little bit messier— You need a KX13 page type you can dedicate to this mapping. Use an existing page type you won’t need in XbyK, or create a dummy page type for this purpose. If you use an existing page type that has pages, they will become
CoffeeVariantitems that you will need to discard migration.
- This is simpler than creating a pipeline behavior, but a little bit messier— You need a KX13 page type you can dedicate to this mapping. Use an existing page type you won’t need in XbyK, or create a dummy page type for this purpose. If you use an existing page type that has pages, they will become
Since this is a small-scale example, we’ll use the fourth option. Defining the schemas in a class mapping also makes it easier to assign schemas to the Coffee type in our second class mapping.
Define a dummy type in KX13
First, log into the administration interface in your Kentico Xperience 13 source project and create a page type with the code name Custom.DummyType. Add a text field called “DummyTypeName”.
With the dummy type in place, we can create a custom mapping that converts it to a new Coffee variant content type.
Create the first class mapping
The first class mapping we run will create all of our product-related reusable field schemas and the content type that represents coffee variants.
We’ll make sure our mapping achieves the following key functions:
- Ensures product taxonomies exist (
EnsureProductTaxonomiesOnce). - Defines reusable schemas (
ProductSchema,ProductSkuSchema,ProductPriceSchema, etc.). - Maps the new content type (
CoffeeVariant). - Exposes
PrepareProductTypes()for DI registration.
Add a new file to the ClassMappings folder of the Migration.Tool.Extensions project.
using CMS.ContentEngine;
using CMS.DataEngine;
using CMS.FormEngine;
using Microsoft.Extensions.DependencyInjection;
using Migration.Tool.Common.Builders;
using Migration.Tool.Common.Helpers;
using Migration.Tool.KXP.Api.Auxiliary;
namespace Migration.Tool.Extensions.ClassMappings;
public static class ProductSetupMapping
{
private const string ProductDiscountCategoryTaxonomyName = "ProductDiscountCategory";
private static readonly Guid productDiscountCategoryTaxonomyGuid = new("D60E2B56-332D-4BD2-A030-981CDE535DCC");
private const string RequiredRuleXml = @"<validationrulesdata><ValidationRuleConfiguration><ValidationRuleIdentifier>Kentico.Administration.RequiredValue</ValidationRuleIdentifier><RuleValues /></ValidationRuleConfiguration></validationrulesdata>";
private const string ProductSchemaName = "ProductSchema";
private const string AmountSchemaName = "AmountSchema";
private const string ProductSkuSchemaName = "ProductSkuSchema";
private const string ProductShippingSchemaName = "ProductShippingSchema";
private const string ProductPriceSchemaName = "ProductPriceSchema";
private const string ProductVariantSchemaName = "ProductVariantSchema";
private const string ProductParentSchemaName = "ProductParentSchema";
private static int productTaxonomiesEnsured;
private static void EnsureProductTaxonomies()
{
var taxonomyInfoProvider = Provider<TaxonomyInfo>.Instance;
EnsureProductTaxonomy(
taxonomyInfoProvider,
ProductDiscountCategoryTaxonomyName,
productDiscountCategoryTaxonomyGuid,
"Product discount category",
"Taxonomy tags that indicate certain discounts that should apply to a product");
// Add more taxonomies as needed, for example, ProductSizeTaxonomy, ProductColorTaxonomy, etc.
}
private static void EnsureProductTaxonomy(IInfoProvider<TaxonomyInfo> taxonomyInfoProvider, string taxonomyName, Guid taxonomyGuid, string taxonomyTitle, string taxonomyDescription)
{
var taxonomy = taxonomyInfoProvider.Get(taxonomyName);
if (taxonomy is null)
{
var newTaxonomy = new TaxonomyInfo
{
TaxonomyName = taxonomyName,
TaxonomyGUID = taxonomyGuid,
TaxonomyTitle = taxonomyTitle,
TaxonomyDescription = taxonomyDescription
};
taxonomyInfoProvider.Set(newTaxonomy);
}
else if (taxonomy.TaxonomyGUID != taxonomyGuid)
{
taxonomy.TaxonomyGUID = taxonomyGuid;
taxonomyInfoProvider.Set(taxonomy);
}
}
// Ensure taxonomies only once, in case multiple schemas reference them.
private static void EnsureProductTaxonomiesOnce()
{
if (Interlocked.Exchange(ref productTaxonomiesEnsured, 1) == 1)
{
return;
}
EnsureProductTaxonomies();
}
private static IEnumerable<IReusableSchemaBuilder> BuildProductReusableSchemas() =>
[
BuildProductSchema(),
BuildAmountSchema(),
BuildProductSkuSchema(),
BuildProductShippingSchema(),
BuildProductPriceSchema(),
BuildProductVariantSchema(),
BuildProductParentSchema(),
];
private static IReusableSchemaBuilder BuildProductSchema()
{
var schema = new ReusableSchemaBuilder(ProductSchemaName, "Product schema", null);
schema.BuildField("ProductSchemaName").WithFactory(() => new FormFieldInfo
{
Name = "ProductSchemaName",
Caption = "Product name",
Guid = new Guid("19411075-2F29-4E38-93CF-2C63B688C291"),
DataType = FieldDataType.Text,
Size = 200,
AllowEmpty = true,
Settings = { ["controlname"] = FormComponents.AdminTextInputComponent }
});
schema.BuildField("ProductSchemaImages").WithFactory(() => new FormFieldInfo
{
Name = "ProductSchemaImages",
Caption = "Product images",
Guid = new Guid("84B06B06-1392-422D-82E5-639ED2FA6340"),
DataType = "contentitemreference",
AllowEmpty = true,
Settings =
{
["controlname"] = FormComponents.AdminContentItemSelectorComponent,
["AllowedContentItemTypeIdentifiers"] = "[\"4e214df1-edd5-4441-a0b7-53849526b1d3\",\"bb17604c-c134-4faa-a401-2727cad93707\"]",
["SelectionType"] = "contentTypes",
["MinimumItems"] = "1",
["MaximumItems"] = "10"
}
});
schema.BuildField("ProductSchemaDescription").WithFactory(() => new FormFieldInfo
{
Name = "ProductSchemaDescription",
Caption = "Product text",
Guid = new Guid("2D9A414E-D306-4E28-AF27-62BA0666EC92"),
DataType = FieldDataType.LongText,
AllowEmpty = true,
Settings = { ["controlname"] = FormComponents.AdminRichTextEditorComponent }
});
return schema;
}
private static IReusableSchemaBuilder BuildAmountSchema()
{
var schema = new ReusableSchemaBuilder(AmountSchemaName, "Amount schema", null);
schema.BuildField("AmountSchemaNumber").WithFactory(() => new FormFieldInfo
{
Name = "AmountSchemaNumber",
Caption = "Amount",
Guid = new Guid("90E4AE11-09E9-4E58-8809-3763F1E7015A"),
DataType = FieldDataType.Decimal,
Size = 19,
Precision = 4,
AllowEmpty = true,
ValidationRuleConfigurationsXmlData = RequiredRuleXml,
Settings = { ["controlname"] = FormComponents.AdminDecimalNumberInputComponent }
});
schema.BuildField("AmountSchemaUnit").WithFactory(() => new FormFieldInfo
{
Name = "AmountSchemaUnit",
Caption = "Product measurement unit",
Guid = new Guid("7F74D2CA-6491-4C0C-A51E-A2B826F7CC82"),
DataType = FieldDataType.Text,
Size = 200,
AllowEmpty = true,
ValidationRuleConfigurationsXmlData = RequiredRuleXml,
Settings =
{
["controlname"] = FormComponents.AdminDropDownComponent,
["Options"] = "kg;kg\r\ng;g\r\nlb;lb\r\noz;oz\r\nl;l\r\nml;ml\r\ngal;gal\r\nfloz;fl oz\r\ncup;cup\r\nm;m\r\ncm;cm\r\nyd;yd\r\nft;ft\r\nin;in",
["OptionsValueSeparator"] = ";"
}
});
return schema;
}
private static IReusableSchemaBuilder BuildProductSkuSchema()
{
var schema = new ReusableSchemaBuilder(ProductSkuSchemaName, "Product SKU Schema", null);
schema.BuildField("ProductSkuSchemaSkuCode").WithFactory(() => new FormFieldInfo
{
Name = "ProductSkuSchemaSkuCode",
Caption = "SKU Code",
Guid = new Guid("426C71D2-E0F3-41FA-8B55-50691C0AFED8"),
DataType = FieldDataType.Text,
Size = 200,
AllowEmpty = true,
Settings = { ["controlname"] = FormComponents.AdminTextInputComponent }
});
return schema;
}
private static IReusableSchemaBuilder BuildProductShippingSchema()
{
var schema = new ReusableSchemaBuilder(ProductShippingSchemaName, "Product shipping schema", null);
schema.BuildField("ProductShippingSchemaShippingType").WithFactory(() => new FormFieldInfo
{
Name = "ProductShippingSchemaShippingType",
Caption = "Product requires shipping",
Guid = new Guid("30676C1A-2D7F-4C01-8ECA-29AA43FD5B40"),
DataType = FieldDataType.Text,
Size = 200,
AllowEmpty = true,
Settings =
{
["controlname"] = FormComponents.AdminDropDownComponent,
["Options"] = "0;Product does not require shipping\r\n1;Product requires shipping to customer\r\n2;Product requires shipping to store\r\n3;Product requires shipping to customer or store",
["OptionsValueSeparator"] = ";"
}
});
schema.BuildField("ProductShippingSchemaShippingWeight").WithFactory(() => new FormFieldInfo
{
Name = "ProductShippingSchemaShippingWeight",
Caption = "Shipping weight",
Guid = new Guid("0D3B1DC5-C894-4F31-8BED-B6C7117D2A8A"),
DataType = FieldDataType.Decimal,
Size = 19,
Precision = 4,
AllowEmpty = true,
Settings = { ["controlname"] = FormComponents.AdminDecimalNumberInputComponent }
});
schema.BuildField("ProductShippingSchemaWeightUnit").WithFactory(() => new FormFieldInfo
{
Name = "ProductShippingSchemaWeightUnit",
Caption = "Weight unit",
Guid = new Guid("1FD98C15-7C08-4C93-A22E-A2B7E966E359"),
DataType = FieldDataType.Text,
Size = 200,
AllowEmpty = true,
Settings =
{
["controlname"] = FormComponents.AdminDropDownComponent,
["Options"] = "kg;kg\r\ng;g\r\nlb;lb\r\noz;oz\r\nl;l\r\nml;ml\r\ngal;gal\r\nfloz;fl oz\r\ncup;cup\r\nm;m\r\ncm;cm\r\nyd;yd\r\nft;ft\r\nin;in",
["OptionsValueSeparator"] = ";"
}
});
return schema;
}
private static IReusableSchemaBuilder BuildProductPriceSchema()
{
var schema = new ReusableSchemaBuilder(ProductPriceSchemaName, "Product price schema", null);
schema.BuildField("ProductPriceSchemaPrice").WithFactory(() => new FormFieldInfo
{
Name = "ProductPriceSchemaPrice",
Caption = "Price",
Guid = new Guid("1BD8BF06-E7ED-435D-AF7F-0DA411E5B587"),
DataType = FieldDataType.Decimal,
Size = 19,
Precision = 2,
AllowEmpty = true,
ValidationRuleConfigurationsXmlData = RequiredRuleXml,
Settings = { ["controlname"] = FormComponents.AdminDecimalNumberInputComponent }
});
schema.BuildField("ProductPriceSchemaDiscountCategory").WithFactory(() =>
{
EnsureProductTaxonomiesOnce();
return new FormFieldInfo
{
Name = "ProductPriceSchemaDiscountCategory",
Caption = "Discount category",
Guid = new Guid("2F50EBC2-3B33-48A6-B0B2-4A486BF617F7"),
DataType = "taxonomy",
AllowEmpty = true,
Settings =
{
["controlname"] = FormComponents.AdminTagSelectorComponent,
["TaxonomyGroup"] = $"[\"{productDiscountCategoryTaxonomyGuid}\"]"
}
};
});
return schema;
}
private static IReusableSchemaBuilder BuildProductVariantSchema()
{
var schema = new ReusableSchemaBuilder(ProductVariantSchemaName, "Product variant schema", null);
schema.BuildField("ProductVariantSchemaCodeName").WithFactory(() => new FormFieldInfo
{
Name = "ProductVariantSchemaCodeName",
Caption = "Variant code name",
Guid = new Guid("C4FED75B-3AC5-430F-BFB8-E464DFCAF864"),
DataType = FieldDataType.Text,
Size = 200,
AllowEmpty = true,
ValidationRuleConfigurationsXmlData = RequiredRuleXml,
Settings =
{
["controlname"] = "Kentico.Administration.CodeName",
["HasAutomaticCodeNameGenerationOption"] = "False",
["IsCollapsed"] = "False"
}
});
return schema;
}
private static IReusableSchemaBuilder BuildProductParentSchema()
{
var schema = new ReusableSchemaBuilder(ProductParentSchemaName, "Product parent schema", "Schema for parent products with variants");
schema.BuildField("ProductParentSchemaVariants").WithFactory(() => new FormFieldInfo
{
Name = "ProductParentSchemaVariants",
Caption = "Variants",
Guid = new Guid("E79BCBA5-39EA-415D-9202-0B712E101330"),
DataType = "contentitemreference",
AllowEmpty = true,
Enabled = true,
Settings =
{
["controlname"] = FormComponents.AdminContentItemSelectorComponent,
// CreateReusableSchemaGuid is deterministic, and what the migration tool uses when it creates schemas.
// This allows us to reference the schema without a hard-coded schema guid.
["AllowedSchemaIdentifiers"] = $"[\"{GuidHelper.CreateReusableSchemaGuid(ProductVariantSchemaName)}\"]",
["SelectionType"] = "reusableFieldSchemas",
["MinimumItems"] = "1"
}
});
return schema;
}
private static IClassMapping BuildVariantMapping()
{
const string coffeeVariantClassName = "DancingGoatCore.CoffeeVariant";
var m = new MultiClassMapping(coffeeVariantClassName, target =>
{
target.ClassName = coffeeVariantClassName;
target.ClassTableName = "DancingGoatCore_CoffeeVariant";
target.ClassDisplayName = "CoffeeVariant";
target.ClassType = ClassType.CONTENT_TYPE;
target.ClassContentTypeType = ClassContentTypeType.REUSABLE;
target.ClassWebPageHasUrl = false;
});
// set new primary key
m.BuildField("CoffeeVariantID").AsPrimaryKey();
m.UseReusableSchema(ProductSchemaName);
m.UseReusableSchema(AmountSchemaName);
m.UseReusableSchema(ProductVariantSchemaName);
m.UseReusableSchema(ProductPriceSchemaName);
m.UseReusableSchema(ProductSkuSchemaName);
m.UseReusableSchema(ProductShippingSchemaName);
m.BuildField("ProductSchemaName")
.SetFrom("Custom.DummyType", "DummyTypeName");
return m;
}
public static IServiceCollection PrepareProductTypes(this IServiceCollection serviceCollection)
{
foreach (var schemaBuilder in BuildProductReusableSchemas())
{
serviceCollection.AddSingleton(typeof(IReusableSchemaBuilder), schemaBuilder);
}
var m = BuildVariantMapping();
serviceCollection.AddSingleton(m);
return serviceCollection;
}
}
Make sure to use SetFrom rather than WithoutSource for at least one field in the Coffee variant content type.
WithoutSource creates a target field without mapping it to a source field, and the migration tool may skip mappings that have no source-bound fields.
Register the first mapping in ServiceCollectionExtensions
Register setup mappings early so schema/type definitions are available before product remodeling runs.
using Migration.Tool.Extensions.ClassMappings;
// ... existing code ...
public static IServiceCollection UseCustomizations(this IServiceCollection services)
{
// ... existing code ...
services.PrepareProductTypes();
// ... existing code ...
return services;
}
// ... existing code ...
Build and run the migration tool (first pass)
With our setup mapping in place, we can run the first migration.
Configure the tool to skip migrating the coffee class for now, excluding DancingGoatCore.Coffee in the CLI’s app settings.
{
...
"Settings": {
...
"EntityConfigurations": {
"CMS_Class": {
"ExcludeCodeNames": [
"DancingGoatCore.Coffee"
]
},
...
Then build the Migration.Tool.CLI project and run it as described in the documentation. For example:
dotnet build
.\bin\Debug\net8.0\Migration.Tool.CLI.exe migrate --sites --users --page-types --pages --custom-modules --media-libraries
Review migration tool configuration and command options in the official docs:
After migration, make sure to remove DancingGoatCore.Coffee from the ExcludeCodeNames setting we configured earlier. Otherwise the coffees will not migrate in the next pass.
{
...
"Settings": {
...
"EntityConfigurations": {
"CMS_Class": {
"ExcludeCodeNames": [
]
},
...
Generate reusable schema and content type code files
Now that the types exist in the target site, we need to ensure that we can work with them in code.
In your target XbyK solution, generate code files for reusable field schemas and reusable content types. For example:
dotnet run -- --kxp-codegen --type "ReusableFieldSchemas" --namespace "DancingGoatCore" --location "../DancingGoat.Entities/{type}/{name}"
dotnet run --no-build -- --kxp-codegen --type "ReusableContentTypes" --location "../DancingGoat.Entities/{type}/{name}"
Copy the generated files to a location that your mapping project can reference (for example, an Entities folder in Migration.Tool.Extensions).
Enable access to other KX13 object types
SKU data in KX13 saves a product’s image as a metafile, referenced by the SkuImagePath property.
The ModelFacade used to access KX13 data does not support every object type by default. To access metafile data, we’ll need to create a model with an ICmsMetaFile interface for ModelFacade. You can see existing models for many KX13 object types in the KVA/Migration.Tool.Source/Model folder.
Thankfully, version-specific models already exist for this class in the Migration.Tool.<Version>/Models projects. Feeding each of these version-specific files to an AI coding assistant, you can quickly generate a model file like this in the KVA/Migration.Tool.Source/Model folder:
using System.Data;
using Migration.Tool.Common;
namespace Migration.Tool.Source.Model;
public partial interface ICmsMetaFile : ISourceModel<ICmsMetaFile>
{
int MetaFileID { get; }
int MetaFileObjectID { get; }
string MetaFileObjectType { get; }
string? MetaFileGroupName { get; }
string MetaFileName { get; }
string MetaFileExtension { get; }
int MetaFileSize { get; }
string MetaFileMimeType { get; }
byte[]? MetaFileBinary { get; }
int? MetaFileImageWidth { get; }
int? MetaFileImageHeight { get; }
Guid MetaFileGUID { get; }
DateTime MetaFileLastModified { get; }
int? MetaFileSiteID { get; }
string? MetaFileTitle { get; }
string? MetaFileDescription { get; }
string? MetaFileCustomData { get; }
static string ISourceModel<ICmsMetaFile>.GetPrimaryKeyName(SemanticVersion version) => version switch
{
{ Major: 11 } => CmsMetaFileK11.GetPrimaryKeyName(version),
{ Major: 12 } => CmsMetaFileK12.GetPrimaryKeyName(version),
{ Major: 13 } => CmsMetaFileK13.GetPrimaryKeyName(version),
_ => throw new InvalidCastException($"Invalid version {version}")
};
static bool ISourceModel<ICmsMetaFile>.IsAvailable(SemanticVersion version) => version switch
{
{ Major: 11 } => CmsMetaFileK11.IsAvailable(version),
{ Major: 12 } => CmsMetaFileK12.IsAvailable(version),
{ Major: 13 } => CmsMetaFileK13.IsAvailable(version),
_ => throw new InvalidCastException($"Invalid version {version}")
};
static string ISourceModel<ICmsMetaFile>.TableName => "CMS_MetaFile";
static string ISourceModel<ICmsMetaFile>.GuidColumnName => "MetaFileGUID"; //assumption, class Guid column doesn't change between versions
static ICmsMetaFile ISourceModel<ICmsMetaFile>.FromReader(IDataReader reader, SemanticVersion version) => version switch
{
{ Major: 11 } => CmsMetaFileK11.FromReader(reader, version),
{ Major: 12 } => CmsMetaFileK12.FromReader(reader, version),
{ Major: 13 } => CmsMetaFileK13.FromReader(reader, version),
_ => throw new InvalidCastException($"Invalid version {version}")
};
}
public partial record CmsMetaFileK11(int MetaFileID, int MetaFileObjectID, string MetaFileObjectType, string? MetaFileGroupName, string MetaFileName, string MetaFileExtension, int MetaFileSize, string MetaFileMimeType, byte[]? MetaFileBinary, int? MetaFileImageWidth, int? MetaFileImageHeight, Guid MetaFileGUID, DateTime MetaFileLastModified, int? MetaFileSiteID, string? MetaFileTitle, string? MetaFileDescription, string? MetaFileCustomData) : ICmsMetaFile, ISourceModel<CmsMetaFileK11>
{
public static bool IsAvailable(SemanticVersion version) => true;
public static string GetPrimaryKeyName(SemanticVersion version) => "MetaFileID";
public static string TableName => "CMS_MetaFile";
public static string GuidColumnName => "MetaFileGUID";
static CmsMetaFileK11 ISourceModel<CmsMetaFileK11>.FromReader(IDataReader reader, SemanticVersion version) => new(
reader.Unbox<int>("MetaFileID"), reader.Unbox<int>("MetaFileObjectID"), reader.Unbox<string>("MetaFileObjectType"), reader.Unbox<string?>("MetaFileGroupName"), reader.Unbox<string>("MetaFileName"), reader.Unbox<string>("MetaFileExtension"), reader.Unbox<int>("MetaFileSize"), reader.Unbox<string>("MetaFileMimeType"), reader.Unbox<byte[]?>("MetaFileBinary"), reader.Unbox<int?>("MetaFileImageWidth"), reader.Unbox<int?>("MetaFileImageHeight"), reader.Unbox<Guid>("MetaFileGUID"), reader.Unbox<DateTime>("MetaFileLastModified"), reader.Unbox<int?>("MetaFileSiteID"), reader.Unbox<string?>("MetaFileTitle"), reader.Unbox<string?>("MetaFileDescription"), reader.Unbox<string?>("MetaFileCustomData")
);
public static CmsMetaFileK11 FromReader(IDataReader reader, SemanticVersion version) => new(
reader.Unbox<int>("MetaFileID"), reader.Unbox<int>("MetaFileObjectID"), reader.Unbox<string>("MetaFileObjectType"), reader.Unbox<string?>("MetaFileGroupName"), reader.Unbox<string>("MetaFileName"), reader.Unbox<string>("MetaFileExtension"), reader.Unbox<int>("MetaFileSize"), reader.Unbox<string>("MetaFileMimeType"), reader.Unbox<byte[]?>("MetaFileBinary"), reader.Unbox<int?>("MetaFileImageWidth"), reader.Unbox<int?>("MetaFileImageHeight"), reader.Unbox<Guid>("MetaFileGUID"), reader.Unbox<DateTime>("MetaFileLastModified"), reader.Unbox<int?>("MetaFileSiteID"), reader.Unbox<string?>("MetaFileTitle"), reader.Unbox<string?>("MetaFileDescription"), reader.Unbox<string?>("MetaFileCustomData")
);
};
public partial record CmsMetaFileK12(int MetaFileID, int MetaFileObjectID, string MetaFileObjectType, string? MetaFileGroupName, string MetaFileName, string MetaFileExtension, int MetaFileSize, string MetaFileMimeType, byte[]? MetaFileBinary, int? MetaFileImageWidth, int? MetaFileImageHeight, Guid MetaFileGUID, DateTime MetaFileLastModified, int? MetaFileSiteID, string? MetaFileTitle, string? MetaFileDescription, string? MetaFileCustomData) : ICmsMetaFile, ISourceModel<CmsMetaFileK12>
{
public static bool IsAvailable(SemanticVersion version) => true;
public static string GetPrimaryKeyName(SemanticVersion version) => "MetaFileID";
public static string TableName => "CMS_MetaFile";
public static string GuidColumnName => "MetaFileGUID";
static CmsMetaFileK12 ISourceModel<CmsMetaFileK12>.FromReader(IDataReader reader, SemanticVersion version) => new(
reader.Unbox<int>("MetaFileID"), reader.Unbox<int>("MetaFileObjectID"), reader.Unbox<string>("MetaFileObjectType"), reader.Unbox<string?>("MetaFileGroupName"), reader.Unbox<string>("MetaFileName"), reader.Unbox<string>("MetaFileExtension"), reader.Unbox<int>("MetaFileSize"), reader.Unbox<string>("MetaFileMimeType"), reader.Unbox<byte[]?>("MetaFileBinary"), reader.Unbox<int?>("MetaFileImageWidth"), reader.Unbox<int?>("MetaFileImageHeight"), reader.Unbox<Guid>("MetaFileGUID"), reader.Unbox<DateTime>("MetaFileLastModified"), reader.Unbox<int?>("MetaFileSiteID"), reader.Unbox<string?>("MetaFileTitle"), reader.Unbox<string?>("MetaFileDescription"), reader.Unbox<string?>("MetaFileCustomData")
);
public static CmsMetaFileK12 FromReader(IDataReader reader, SemanticVersion version) => new(
reader.Unbox<int>("MetaFileID"), reader.Unbox<int>("MetaFileObjectID"), reader.Unbox<string>("MetaFileObjectType"), reader.Unbox<string?>("MetaFileGroupName"), reader.Unbox<string>("MetaFileName"), reader.Unbox<string>("MetaFileExtension"), reader.Unbox<int>("MetaFileSize"), reader.Unbox<string>("MetaFileMimeType"), reader.Unbox<byte[]?>("MetaFileBinary"), reader.Unbox<int?>("MetaFileImageWidth"), reader.Unbox<int?>("MetaFileImageHeight"), reader.Unbox<Guid>("MetaFileGUID"), reader.Unbox<DateTime>("MetaFileLastModified"), reader.Unbox<int?>("MetaFileSiteID"), reader.Unbox<string?>("MetaFileTitle"), reader.Unbox<string?>("MetaFileDescription"), reader.Unbox<string?>("MetaFileCustomData")
);
};
public partial record CmsMetaFileK13(int MetaFileID, int MetaFileObjectID, string MetaFileObjectType, string? MetaFileGroupName, string MetaFileName, string MetaFileExtension, int MetaFileSize, string MetaFileMimeType, byte[]? MetaFileBinary, int? MetaFileImageWidth, int? MetaFileImageHeight, Guid MetaFileGUID, DateTime MetaFileLastModified, int? MetaFileSiteID, string? MetaFileTitle, string? MetaFileDescription, string? MetaFileCustomData) : ICmsMetaFile, ISourceModel<CmsMetaFileK13>
{
public static bool IsAvailable(SemanticVersion version) => true;
public static string GetPrimaryKeyName(SemanticVersion version) => "MetaFileID";
public static string TableName => "CMS_MetaFile";
public static string GuidColumnName => "MetaFileGUID";
static CmsMetaFileK13 ISourceModel<CmsMetaFileK13>.FromReader(IDataReader reader, SemanticVersion version) => new(
reader.Unbox<int>("MetaFileID"), reader.Unbox<int>("MetaFileObjectID"), reader.Unbox<string>("MetaFileObjectType"), reader.Unbox<string?>("MetaFileGroupName"), reader.Unbox<string>("MetaFileName"), reader.Unbox<string>("MetaFileExtension"), reader.Unbox<int>("MetaFileSize"), reader.Unbox<string>("MetaFileMimeType"), reader.Unbox<byte[]?>("MetaFileBinary"), reader.Unbox<int?>("MetaFileImageWidth"), reader.Unbox<int?>("MetaFileImageHeight"), reader.Unbox<Guid>("MetaFileGUID"), reader.Unbox<DateTime>("MetaFileLastModified"), reader.Unbox<int?>("MetaFileSiteID"), reader.Unbox<string?>("MetaFileTitle"), reader.Unbox<string?>("MetaFileDescription"), reader.Unbox<string?>("MetaFileCustomData")
);
public static CmsMetaFileK13 FromReader(IDataReader reader, SemanticVersion version) => new(
reader.Unbox<int>("MetaFileID"), reader.Unbox<int>("MetaFileObjectID"), reader.Unbox<string>("MetaFileObjectType"), reader.Unbox<string?>("MetaFileGroupName"), reader.Unbox<string>("MetaFileName"), reader.Unbox<string>("MetaFileExtension"), reader.Unbox<int>("MetaFileSize"), reader.Unbox<string>("MetaFileMimeType"), reader.Unbox<byte[]?>("MetaFileBinary"), reader.Unbox<int?>("MetaFileImageWidth"), reader.Unbox<int?>("MetaFileImageHeight"), reader.Unbox<Guid>("MetaFileGUID"), reader.Unbox<DateTime>("MetaFileLastModified"), reader.Unbox<int?>("MetaFileSiteID"), reader.Unbox<string?>("MetaFileTitle"), reader.Unbox<string?>("MetaFileDescription"), reader.Unbox<string?>("MetaFileCustomData")
);
};
Use this approach whenever you need to work with an object type that is not included in the migration tool out of the box.
Migrate product data
With the content types and supporting files in place, we can finally create the mapping that handles core SKU transfer for DancingGoatCore.Coffee.
The mapping should have the following key responsibilities:
- Transfer standard coffee-related page type fields.
- Map SKU fields from documents and SKUs where applicable.
- Create media file content items from KX13 SKU metafiles and link them to products.
- Ensure variant content items exist and link them to parent products.
To make things easy, let’s start by creating a new class mapping in Migration.Tool.Extensions/ClassMappings and adding all the using directives and constants we will need throughout this exercise.
// Partial — see the complete CoffeeProductMapping.cs for all using directives
using CMS.ContentEngine;
using CMS.Core;
using CMS.DataEngine;
using CMS.FormEngine;
using CMS.Helpers;
using DancingGoatCore;
using Legacy;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.DependencyInjection;
using Migration.Tool.Common.Builders;
using Migration.Tool.Source;
using Migration.Tool.Source.Model;
namespace Migration.Tool.Extensions.ClassMappings;
public static class CoffeeProductMapping
{
// Constants
private const int ADMIN_ID = 53;
private const string PRODUCT_SCHEMA_NAME = "ProductSchemaName";
private const string PRODUCT_SKU_SCHEMA_SKU_CODE = "ProductSkuSchemaSkuCode";
private const string PRODUCT_VARIANT_SCHEMA_CODE_NAME = "ProductVariantSchemaCodeName";
private const string PRODUCT_PRICE_SCHEMA_PRICE = "ProductPriceSchemaPrice";
private const string AMOUNT_SCHEMA_NUMBER = "AmountSchemaNumber";
private const string AMOUNT_SCHEMA_UNIT = "AmountSchemaUnit";
private const string SHIPPING_SCHEMA_WEIGHT = "ProductShippingSchemaShippingWeight";
private const string SHIPPING_SCHEMA_WEIGHT_UNIT = "ProductShippingSchemaWeightUnit";
private const string AMOUNT_UNIT_LB = "lb";
private const string KENTICO_DEFAULT_WORKSPACE = "KenticoDefault";
private const string COFFEE_ID_FIELD = "CoffeeID";
private const string CLASS_NAME = "DancingGoatCore.Coffee";
// ... existing code ...
Work with the class mapping lifecycle
We’ll be using a class mapping to migrate SKU data into Xperience by Kentico, which has a few quirks.
Class mappings apply to the dependency injection container’s service collection through extension methods, meaning we have to work with static code that runs at startup.
However, the ConvertFrom method, which we will use to look up SKU data, defines functions that execute later on in the lifecycle, when KX13 page migration occurs.
We can’t use typical dependency injection to resolve services, but we can define static properties to hold services and cache query data, to reduce performance concerns when the migration tool calls these functions for every page, materializing services and retrieving data only once.
We need to store collections of the following objects from Kentico Xperience 13:
- SKUs - the SKU object type represents both parent products and their variants
- Metafiles - Metafiles represent binary files associated with non-page objects, in this case, SKU images.
- Documents - the Document object type represents language-specific page data with fields common to all page types.
We also need to resolve instances of the following:
ModelFacadeto query the KX13 database.ToolConfigurationto access the app settings of the Migration.Tool.CLI project.IContentQueryExecutorto query existing XbyK content items.IContentItemManagerto create new content items in XbyK.
// Partial — see the complete CoffeeProductMapping.cs for all using directives
// ... existing code ...
// Cached KX13 data to optimize lookups during mapping
// Depending on the amount of data, you may want to implement a more sophisticated caching strategy or query data on demand
private static IEnumerable<IComSku>? kx13Skus;
private static IEnumerable<ICmsMetaFile>? skuMetaFiles;
private static IEnumerable<ICmsDocument>? kx13Documents;
// Services and facades
private static ModelFacade? modelFacade;
private static Common.ToolConfiguration? toolConfiguration;
private static IContentQueryExecutor? contentQueryExecutor;
private static IContentItemManager? contentItemManager;
// ... existing code ...
// Initialize the services and cached data needed for the mapping.
private static void EnsureInitialized()
{
modelFacade ??= KsCoreDiExtensions.ServiceProvider.GetRequiredService<ModelFacade>();
toolConfiguration ??= KsCoreDiExtensions.ServiceProvider.GetRequiredService<Common.ToolConfiguration>();
contentQueryExecutor ??= Service.Resolve<IContentQueryExecutor>();
contentItemManager ??= Service.Resolve<IContentItemManagerFactory>().Create(ADMIN_ID);
kx13Skus ??= modelFacade.SelectAll<IComSku>();
skuMetaFiles ??= GetSkuMetaFiles(modelFacade, kx13Skus);
kx13Documents ??= GetKX13CoffeeDocuments(modelFacade);
}
// Access data from KX13
// Queries a single value from a KX13 table with `WHERE <column> = <value>` logic
private static T GetValueFromTable<T>(
ModelFacade modelFacade,
string tableName,
string columnName,
string whereColumnName,
object whereValue) =>
modelFacade.Select(
$"SELECT TOP 1 {columnName} FROM {tableName} WHERE {whereColumnName} = @whereValue",
(reader, _) => reader.IsDBNull(0) ? default : reader.GetFieldValue<T>(0),
new SqlParameter("whereValue", whereValue)
).FirstOrDefault()!;
// Retrieve all meta files associated with SKUs from KX13 database
private static IEnumerable<ICmsMetaFile> GetSkuMetaFiles(ModelFacade modelFacade, IEnumerable<IComSku> skus)
{
var metaFileGuids = GetSkuMetafileGuids(skus);
return modelFacade.Select<ICmsMetaFile>(
where: $"MetaFileGuid IN ('{string.Join("','", metaFileGuids)}')",
orderBy: "MetaFileID"
).ToList();
}
// Retrieve all coffee documents from KX13 database.
private static IEnumerable<ICmsDocument> GetKX13CoffeeDocuments(ModelFacade modelFacade)
{
int coffeeClassId = GetValueFromTable<int>(modelFacade,
tableName: "CMS_Class",
columnName: "ClassID",
whereColumnName: "ClassName",
whereValue: CLASS_NAME);
var nodeIds = modelFacade.Select(
$"SELECT NodeID FROM CMS_Tree WHERE NodeClassID = @coffeeClassId",
(reader, _) => reader.GetInt32(0),
// Cast value as object to avoid SqlParameter constructor ambiguity with int overload
new SqlParameter("coffeeClassId", (object)coffeeClassId)
).ToArray();
string nodeIdList = string.Join(",", nodeIds);
return modelFacade.Select<ICmsDocument>(
where: $"DocumentNodeID IN ({nodeIdList})",
orderBy: "DocumentID"
).ToList();
}
// ... existing code ...
Then, we can add methods to extract data from our enumerable sets instead of querying the database directly.
Take the enumerables as parameters instead of accessing the static properties directly, so that you have the option to pre-filter the collection.
// Partial — see the complete CoffeeProductMapping.cs for all using directives
// ... existing code ...
// Retrieve all SKU variants for a given KX13 SKU.
private static IEnumerable<IComSku> GetKX13SkuVariants(IEnumerable<IComSku> allKx13Skus, int skuId) =>
allKx13Skus.Where(sku => sku.SKUParentSKUID == skuId);
// Retrieves a value from the KX13 SKU with the given ID based on the provided selector.
private static T? GetSkuValue<T>(IEnumerable<IComSku> skus, int skuId, Func<IComSku, T> selector) =>
skus.Where(sku => sku.SKUID == skuId)
.Select(selector)
.FirstOrDefault();
// Retrieves a value from the KX13 metafile with the given GUID based on the provided selector.
private static T? GetMetaFileValue<T>(IEnumerable<ICmsMetaFile> metaFiles, Guid metaFileGuid, Func<ICmsMetaFile, T> selector) =>
metaFiles.Where(mf => mf.MetaFileGUID == metaFileGuid)
.Select(selector)
.FirstOrDefault();
// Finds the KX13 SKU with the provided ID.
private static IComSku? GetKX13Sku(IEnumerable<IComSku> skus, int skuId) =>
skus.FirstOrDefault(sku => sku.SKUID == skuId);
// Finds the KX13 document associated with the content item's ConvertorTreeNodeContext.
private static ICmsDocument? GetDocument(IEnumerable<ICmsDocument> documents, IConvertorContext context)
{
int documentId = (context as ConvertorTreeNodeContext)?.DocumentId ?? 0;
return documents.FirstOrDefault(doc => doc.DocumentID == documentId);
}
// ... existing code ...
Directly transfer coffee fields
You can transfer many of the fields for this mapping directly, as in a standard class mapping.
// Partial — see the complete CoffeeProductMapping.cs for all using directives
// ... existing code ...
public static IServiceCollection RemodelProducts(this IServiceCollection serviceCollection)
{
var m = new MultiClassMapping(CLASS_NAME, target =>
{
target.ClassName = CLASS_NAME;
target.ClassTableName = "DancingGoatCore_Coffee";
target.ClassDisplayName = "Coffee";
target.ClassType = ClassType.CONTENT_TYPE;
target.ClassContentTypeType = ClassContentTypeType.REUSABLE;
target.ClassWebPageHasUrl = false;
});
// set new primary key
m.BuildField("CoffeeID").AsPrimaryKey();
// Apply product schema and parent schema, as coffee has variants
m.UseReusableSchema("ProductSchema");
m.UseReusableSchema("ProductParentSchema");
// Standard one-to-one field mappings
m
.BuildField("CoffeeFarm")
.SetFrom(CLASS_NAME, "CoffeeFarm", true)
.WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Farm RM"));
m
.BuildField("CoffeeCountry")
.WithFieldPatch(f => f.Caption = "Country RM")
.SetFrom(CLASS_NAME, "CoffeeCountry", true);
m
.BuildField("CoffeeVariety")
.SetFrom(CLASS_NAME, "CoffeeVariety", true)
.WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Variety RM"));
m
.BuildField("CoffeeProcessing")
.SetFrom(CLASS_NAME, "CoffeeProcessing", true)
.WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Processing RM"));
m
.BuildField("CoffeeAltitude")
.SetFrom(CLASS_NAME, "CoffeeAltitude", true)
.WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Altitude RM"));
m
.BuildField("CoffeeIsDecaf")
.SetFrom(CLASS_NAME, "CoffeeIsDecaf", true)
.WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "IsDecaf RM"));
// register class mapping
serviceCollection.AddSingleton<IClassMapping>(m);
return serviceCollection;
}
// ... existing code ...
Access translated product fields
Some SKU fields in KX13 are translated on multilingual sites, and their values are stored outside of the SKU table, in CMS_Document.
To access these values, you can use the convertor context with the method we created earlier to retrieve the document by ID.
// Partial — see the complete CoffeeProductMapping.cs for all using directives
// ... existing code ...
m
.BuildField("ProductSchemaName")
.ConvertFrom(CLASS_NAME, COFFEE_ID_FIELD, false, (value, context) =>
{
EnsureInitialized();
var document = GetDocument(kx13Documents!, context);
if (!string.IsNullOrWhiteSpace(document?.DocumentSKUName))
{
return document.DocumentSKUName;
}
// Fall back to the non-translated SKU value if there is an issue retrieving the document value
int? skuId = (context as ConvertorTreeNodeContext)?.NodeSKUID;
if (skuId is not null and > 0)
{
return GetSkuValue(kx13Skus!, skuId.Value, sku => sku.SKUName);
}
return null;
});
m
.BuildField("ProductSchemaDescription")
.ConvertFrom(CLASS_NAME, COFFEE_ID_FIELD, false, (value, context) =>
{
EnsureInitialized();
var document = GetDocument(kx13Documents!, context);
var translatedLongDescription = document?.DocumentSKUDescription;
var translatedShortDescription = document?.DocumentSKUShortDescription;
// Fall back to short description if full description unavailable
var translatedDescription = !string.IsNullOrWhiteSpace(translatedLongDescription)
? translatedLongDescription
: translatedShortDescription;
if (!string.IsNullOrWhiteSpace(translatedDescription))
{
return translatedDescription;
}
// Fall back to SKU values if translated values unavailable
int? skuId = (context as ConvertorTreeNodeContext)?.NodeSKUID;
if (!skuId.HasValue)
{
return null;
}
var sku = GetKX13Sku(kx13Skus!, skuId.Value);
var skuDescription = !string.IsNullOrWhiteSpace(sku?.SKUDescription)
? sku.SKUDescription
: sku?.SKUShortDescription;
return skuDescription;
});
// ... existing code ...
Handle SKU images and variants
Next, we’ll work on mappings for data that requires extra content items in XbyK: SKU Images and SKU Variants.
Optional language functionality
In Kentico Xperience 13, SKU images and variant data are not translated across languages. Xperience by Kentico, however, supports multiple language versions for the corresponding content items.
This is optional—if you set up language fallbacks, you can create one version of a product image in the default language and all languages will use it.
This guide demonstrates how to populate multiple language versions during migration, for easier updating with language-specific images in the future. Feel free to simplify this code if your scenario does not require language versions.
Migrate product images
By default, the migration tool creates a reusable Legacy.MediaFile content type to replace files from media libraries. For the sake of this example, let’s convert our SKU metafiles to this type, to avoid creating another.
For each SKU, we can use the SKUImagePath to construct an absolute URL for the metafile, and download it from the running KX13 site.
We’ll need to create a content item for each image and use ensure logic to prevent duplicates in future runs. We’ll also handle languages, adding a corresponding media file for each coffee language version we migrate.
// Partial — see the complete CoffeeProductMapping.cs for all using directives
// ... existing code ...
// Language utilities
// Maps KX13 culture codes to XbyK language code names.
private static string GetLanguageFromCultureCode(string? cultureCode) => cultureCode switch
{
// Language code names taken from the XbyK database
"es-ES" => "es-ES",
"en-US" or _ => "en"
};
// Returns the XbyK content language ID for the given language name, or null if not found.
private static int? GetContentLanguageId(string languageName)
{
var contentLanguageInfoProvider = Provider<ContentLanguageInfo>.Instance;
var languageInfo = contentLanguageInfoProvider.Get(languageName);
return languageInfo?.ContentLanguageID;
}
// Generic XbyK content item retrieval
// Executes a content item query for the given content type and returns mapped results.
private static IEnumerable<T> GetContentItems<T>(string contentTypeName, Func<ContentTypeQueryParameters, ContentTypeQueryParameters> queryConfiguration, IContentQueryExecutor executor) where T : IContentItemFieldsSource
{
var builder = new ContentItemQueryBuilder();
builder.ForContentType(contentTypeName, q => queryConfiguration(q));
var contentItems = executor.GetMappedResult<T>(builder,
new ContentQueryExecutionOptions() { ForPreview = true })
.GetAwaiter().GetResult();
return contentItems;
}
// Image file utilities
// Queries existing XbyK media file content items by code name.
private static IEnumerable<MediaFile> GetExistingMediaFiles(string codeName, IContentQueryExecutor contentQueryExecutor) =>
GetContentItems<MediaFile>(MediaFile.CONTENT_TYPE_NAME, subqueryConfiguration =>
subqueryConfiguration.Where(where => where
.WhereEquals(nameof(ContentItemFields.ContentItemName), codeName)), contentQueryExecutor);
// Extracts meta file GUIDs from all SKUs that have an image path.
private static IEnumerable<Guid> GetSkuMetafileGuids(IEnumerable<IComSku> skus) =>
skus.Where(sku => !string.IsNullOrWhiteSpace(sku.SKUImagePath))
.Select(GetMetaFileGuidFromSku)
.Where(guid => guid.HasValue)
.Select(guid => guid!.Value);
// Extracts the meta file GUID from a SKU's image path, if present.
private static Guid? GetMetaFileGuidFromSku(IComSku sku)
{
if (string.IsNullOrWhiteSpace(sku.SKUImagePath))
{
return null;
}
return GetMetaFileGuidFromPath(sku.SKUImagePath);
}
// Parses a metafile GUID from a KX13 metafile URL path (e.g. ~/getmetafile/{guid}/filename).
private static Guid? GetMetaFileGuidFromPath(string kx13SkuMetaFilePath)
{
// In Dancing Goat, SKUImagePath uses meta file URLs with the format
// ~/getmetafile/01a4223b-fc43-4635-8658-878a7539b8c5/cup-personalized-small
var urlParts = kx13SkuMetaFilePath.Trim().Split('/');
string guidSegment = urlParts[^2];
if (Guid.TryParse(guidSegment, out Guid metaFileGuid))
{
return metaFileGuid;
}
return null;
}
// Performs a synchronous HTTP GET request and returns the response.
// Used for retrieving meta file from KX13
private static HttpResponseMessage GetHttpResponse(string url)
{
using var httpClient = new HttpClient();
return httpClient.GetAsync(url)
.GetAwaiter().GetResult();
}
// Determines the file extension from the HTTP response's Content-Type header.
private static string GetMetafileExtension(HttpResponseMessage response)
{
// Determine extension from Content-Type header (e.g., "image/jpeg" -> ".jpeg")
string contentType = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream";
return contentType switch
{
"image/jpeg" => ".jpg",
"image/png" => ".png",
"image/gif" => ".gif",
"image/webp" => ".webp",
"image/bmp" => ".bmp",
"image/svg+xml" => ".svg",
"image/tiff" => ".tiff",
_ => ".bin"
};
}
// Downloads a KX13 SKU metafile image and ensures a corresponding XbyK media file content item exists, creating or adding a language version as needed.
private static Guid? EnsureMediaFileForSkuMetaFileImage(string kx13SkuMetaFilePath, ICmsDocument? document, IContentQueryExecutor contentQueryExecutor, IContentItemManager contentItemManager)
{
string language = GetLanguageFromCultureCode(document?.DocumentCulture);
var metaFileGuid = GetMetaFileGuidFromPath(kx13SkuMetaFilePath);
if (metaFileGuid is null)
{
return null;
}
var response = GetHttpResponse(kx13SkuMetaFilePath);
// Read into a MemoryStream so we know the size
var memoryStream = new MemoryStream();
response.Content.CopyToAsync(memoryStream).GetAwaiter().GetResult();
memoryStream.Position = 0;
string metaFileExtension = GetMetafileExtension(response);
// In Dancing Goat, SKUImagePath uses meta file URLs with the format
// ~/getmetafile/01a4223b-fc43-4635-8658-878a7539b8c5/cup-personalized-small
var urlParts = kx13SkuMetaFilePath.Trim().Split('/');
string metaFileName = $"{urlParts[^1]}{metaFileExtension}";
string? oldMetaFileTitle = GetMetaFileValue(skuMetaFiles!, metaFileGuid.Value, mf => mf.MetaFileTitle);
string mediaFileTitle = !string.IsNullOrWhiteSpace(oldMetaFileTitle) ? oldMetaFileTitle : metaFileName;
string mediaFileCodeName = ValidationHelper.GetCodeName(mediaFileTitle);
var existingMediaFiles = GetExistingMediaFiles(mediaFileCodeName, contentQueryExecutor);
int languageId = GetContentLanguageId(language) ?? 0;
// If there are existing variants with the given SKU code, but not in the current language, add a language version
// If you do not plan to have any language-specific product images in the future, consider skipping this step
// Instead, create a single media file in the default language only, and use language fallbacks
if (existingMediaFiles.Any() && !existingMediaFiles.Any(v => v.SystemFields.ContentItemCommonDataContentLanguageID == languageId))
{
var mediaFileItemId = existingMediaFiles.First().SystemFields.ContentItemID;
var mediaFileItemGuid = existingMediaFiles.First().SystemFields.ContentItemGUID;
return AddMediaFileLanguageVersion(
metaFileName: metaFileName,
metaFileExtension: metaFileExtension,
metaFileGuid: metaFileGuid.Value,
memoryStream: memoryStream,
mediaFileItemId: mediaFileItemId,
mediaFileItemGuid: mediaFileItemGuid,
mediaFileTitle: mediaFileTitle,
language: language,
contentItemManager: contentItemManager);
}
else if (!existingMediaFiles.Any())
{
return CreateMediaFile(
metaFileName: metaFileName,
metaFileExtension: metaFileExtension,
metaFileGuid: metaFileGuid.Value,
memoryStream: memoryStream,
mediaFileTitle: mediaFileTitle,
mediaFileCodeName: mediaFileCodeName,
language: language,
contentItemManager: contentItemManager);
}
return existingMediaFiles.First().SystemFields.ContentItemGUID;
}
// Creates a new XbyK media file content item from the provided meta file data.
private static Guid? CreateMediaFile(string metaFileName,
string metaFileExtension,
Guid metaFileGuid,
MemoryStream memoryStream,
string mediaFileTitle,
string mediaFileCodeName,
string language,
IContentItemManager contentItemManager)
{
var assetMetadata = new ContentItemAssetMetadata
{
Extension = metaFileExtension,
Identifier = metaFileGuid,
LastModified = DateTime.Now,
Name = metaFileName,
Size = memoryStream.Length
};
var fileSource = new ContentItemAssetStreamSource(
(CancellationToken ct) => Task.FromResult<Stream>(memoryStream));
var assetWithSource = new ContentItemAssetMetadataWithSource(fileSource, assetMetadata);
var createParams = new CreateContentItemParameters(
MediaFile.CONTENT_TYPE_NAME,
mediaFileCodeName,
mediaFileTitle,
language,
KENTICO_DEFAULT_WORKSPACE)
{
IsSecured = false
};
// Use assetWithSource in ContentItemData when creating/updating the content item
var itemData = new ContentItemData(new Dictionary<string, object>
{
{ nameof(MediaFile.LegacyMediaFileAsset), assetWithSource },
{ nameof(MediaFile.LegacyMediaFileTitle), mediaFileTitle }
});
// Create the content item in the database
int newId = contentItemManager.Create(createParams, itemData).GetAwaiter().GetResult();
if (newId <= 0)
{
throw new Exception($"Failed to create media file content item for meta file {metaFileName} ({metaFileGuid}).");
}
if (!contentItemManager.TryPublish(newId, language).GetAwaiter().GetResult())
{
throw new Exception($"Could not publish media file content item with ID {newId}.");
}
Guid? newGuid = CMS.ContentEngine.Internal.ContentItemInfo.Provider.Get(newId)?.ContentItemGUID;
return newGuid;
}
// Adds a language version to an existing XbyK media file content item.
private static Guid? AddMediaFileLanguageVersion(string metaFileName,
string metaFileExtension,
Guid metaFileGuid,
MemoryStream memoryStream,
int mediaFileItemId,
Guid mediaFileItemGuid,
string mediaFileTitle,
string language,
IContentItemManager contentItemManager)
{
var assetMetadata = new ContentItemAssetMetadata
{
Extension = metaFileExtension,
Identifier = metaFileGuid,
LastModified = DateTime.Now,
Name = metaFileName,
Size = memoryStream.Length
};
var fileSource = new ContentItemAssetStreamSource(
(CancellationToken ct) => Task.FromResult<Stream>(memoryStream));
var assetWithSource = new ContentItemAssetMetadataWithSource(fileSource, assetMetadata);
var languageVariantParams = new CreateLanguageVariantParameters(mediaFileItemId,
mediaFileTitle,
language);
var contentItemData = new ContentItemData(new Dictionary<string, object>
{
{ nameof(MediaFile.LegacyMediaFileAsset), assetWithSource },
{ nameof(MediaFile.LegacyMediaFileTitle), mediaFileTitle }
});
if (!contentItemManager.TryCreateLanguageVariant(languageVariantParams, contentItemData).GetAwaiter().GetResult())
{
throw new Exception($"Unable to create language variant for existing media file content item with ID {mediaFileItemId} in language {language}.");
}
if (!contentItemManager.TryPublish(mediaFileItemId, language).GetAwaiter().GetResult())
{
throw new Exception($"Could not publish media file content item with ID {mediaFileItemId}.");
}
return mediaFileItemGuid;
}
// ... existing code ...
Make sure to configure source instance API discovery in the Migration.Tool.CLI project’s appsettings.json. Even if you don’t use the API discovery feature elsewhere, this code relies on its SourceInstanceUri to access SKU metafiles.
Now that we have code to handle media file creation based on KX13 metafiles, let’s set up the field mapping.
Use the EnsureMediaFileForSkuMetaFileImage method to retrieve the GUID of the new media file, and package it in a JSON array.
// Partial — see the complete CoffeeProductMapping.cs for all using directives
// ... existing code ...
m
.BuildField("ProductSchemaImages")
.ConvertFrom(CLASS_NAME, COFFEE_ID_FIELD, true, (value, context) =>
{
EnsureInitialized();
int? skuId = (context as ConvertorTreeNodeContext)?.NodeSKUID;
if (skuId is null or <= 0)
{
return null;
}
var document = GetDocument(kx13Documents!, context);
var kx13Sku = GetKX13Sku(kx13Skus!, skuId.Value);
if (kx13Sku is null || document is null || string.IsNullOrWhiteSpace(kx13Sku.SKUImagePath))
{
return null;
}
string? fullImageUrl = GetFullMetaFileUrl(kx13Sku.SKUImagePath, toolConfiguration!);
if (string.IsNullOrWhiteSpace(fullImageUrl))
{
return null;
}
var metaFileGuid = EnsureMediaFileForSkuMetaFileImage(fullImageUrl, document, contentQueryExecutor!, contentItemManager!);
if (!metaFileGuid.HasValue)
{
return null;
}
// KX13 SKUs store a single path in SKUImagePath, so we don't need to worry about multiple images per SKU here, though multiples are supported in XbyK
return $"[{{\"Identifier\":\"{metaFileGuid.Value}\"}}]";
});
// ... existing code ...
Transfer product variants
The process to migrate SKU variants is similar to SKU images, but you need to create multiple CoffeeVariant items and manage multiple references in your JSON array.
// Partial — see the complete CoffeeProductMapping.cs for all using directives
// ... existing code ...
// Utilities for XbyK variants
// Queries existing XbyK content items of the given type whose SKU code matches any of the provided KX13 variants.
private static IEnumerable<T> GetExistingXbyKVariants<T>(IEnumerable<IComSku> kx13Variants, string variantContentTypeName, IContentQueryExecutor contentQueryExecutor)
where T : IContentItemFieldsSource =>
GetContentItems<T>(variantContentTypeName, subqueryConfiguration =>
subqueryConfiguration.Where(where => where
.WhereIn(nameof(IProductSkuSchema.ProductSkuSchemaSkuCode), kx13Variants.Select(v => v.SKUNumber))), contentQueryExecutor);
// Ensures all KX13 variants exist as XbyK content items (creating or adding language versions as needed) and returns a JSON array of content item references.
private static async Task<string> EnsureXbyKVariants(IEnumerable<IComSku> kx13Variants, IEnumerable<ICmsDocument> kx13Documents, IConvertorContext context, IContentQueryExecutor contentQueryExecutor, IContentItemManager contentItemManager)
{
var document = GetDocument(kx13Documents, context);
string language = GetLanguageFromCultureCode(document?.DocumentCulture);
if (!kx13Variants.Any())
{
return string.Empty;// new List<ContentItemReference>();
}
var xByKVariants = GetExistingXbyKVariants<CoffeeVariant>(kx13Variants, CoffeeVariant.CONTENT_TYPE_NAME, contentQueryExecutor);
int languageId = GetContentLanguageId(language) ?? 0;
var createdVariants = new List<Guid>();
foreach (var kx13Variant in kx13Variants)
{
var existingVariants = xByKVariants.Where(v =>
string.Equals(v.ProductSkuSchemaSkuCode, kx13Variant.SKUNumber, StringComparison.InvariantCultureIgnoreCase));
// If there are existing variants with the given SKU code, but not in the current language, add a language version
if (existingVariants.Any() && !existingVariants.Any(v => v.SystemFields.ContentItemCommonDataContentLanguageID == languageId))
{
// KX13 does not support multilingual translations for variants, but XbyK does
// If you do not plan to expand variants with language-specific data in the future, consider skipping this step
// Instead, create variants only in the default language and enable language fallbacks
var itemGuid = await AddVariantLanguageVersion(
existingVariants: existingVariants,
kx13Variant: kx13Variant,
language: language,
languageId: languageId,
contentItemManager: contentItemManager);
createdVariants.Add(itemGuid);
}
// If there are no existing product variants with the given SKU code, create a new content item
else if (!existingVariants.Any())
{
var newItemGuid = await CreateVariant(kx13Variant, language, contentItemManager);
createdVariants.Add(newItemGuid);
}
// If there are existing variants in the current language, reference them.
else
{
createdVariants.AddRange(existingVariants.Select(v => v.SystemFields.ContentItemGUID));
}
}
string elements = string.Join(", ", createdVariants.Select(itemGuid => $"{{\"Identifier\":\"{itemGuid}\"}}").Distinct());
string result = $"[{elements}]";
return result;
}
// Add a language version for an existing product variant in XbyK in the given language
private static async Task<Guid> AddVariantLanguageVersion(IEnumerable<CoffeeVariant> existingVariants, IComSku kx13Variant, string language, int languageId, IContentItemManager contentItemManager)
{
var itemId = existingVariants.First().SystemFields.ContentItemID;
var itemGuid = existingVariants.First().SystemFields.ContentItemGUID;
var languageVariantParams = new CreateLanguageVariantParameters(itemId,
kx13Variant.SKUName,
language);
var contentItemData = new ContentItemData();
// These fields are NOT translated across languages like those with document equivalents (DocumentSKUName, DocumentSKUDescription, etc.)
// KX13 variants do not correspond to documents, so there is no translation for them
contentItemData.SetValue(PRODUCT_SCHEMA_NAME, kx13Variant.SKUName);
contentItemData.SetValue(PRODUCT_SKU_SCHEMA_SKU_CODE, kx13Variant.SKUNumber);
contentItemData.SetValue(PRODUCT_VARIANT_SCHEMA_CODE_NAME, Uri.EscapeDataString(kx13Variant.SKUNumber ?? Guid.NewGuid().ToString()));
contentItemData.SetValue(PRODUCT_PRICE_SCHEMA_PRICE, kx13Variant.SKUPrice);
contentItemData.SetValue(AMOUNT_SCHEMA_NUMBER, kx13Variant.SKUWeight);
contentItemData.SetValue(AMOUNT_SCHEMA_UNIT, AMOUNT_UNIT_LB);
contentItemData.SetValue(SHIPPING_SCHEMA_WEIGHT, kx13Variant.SKUWeight);
contentItemData.SetValue(SHIPPING_SCHEMA_WEIGHT_UNIT, AMOUNT_UNIT_LB);
if (!await contentItemManager.TryCreateLanguageVariant(languageVariantParams, contentItemData))
{
var existingLanguages = existingVariants.Select(v => v.SystemFields.ContentItemCommonDataContentLanguageID);
throw new Exception($"Unable to create language variant for existing content item with ID {itemId} in language {language} ({languageId}). Existing languages: {string.Join(", ", existingLanguages)}.");
}
if (!await contentItemManager.TryPublish(itemId, language))
{
throw new Exception($"Could not publish content item with ID {itemId} in language {language} ({languageId}).");
}
return itemGuid;
}
// Create a product variant in the provided language in XbyK based on a KX13 variant, and return the new content item's GUID
private static async Task<Guid> CreateVariant(IComSku kx13Variant, string language, IContentItemManager contentItemManager)
{
var createParams = new CreateContentItemParameters(
CoffeeVariant.CONTENT_TYPE_NAME,
null,
kx13Variant.SKUName,
language,
KENTICO_DEFAULT_WORKSPACE)
{
IsSecured = false
};
var contentItemData = new ContentItemData();
contentItemData.SetValue(PRODUCT_SCHEMA_NAME, kx13Variant.SKUName);
contentItemData.SetValue(PRODUCT_SKU_SCHEMA_SKU_CODE, kx13Variant.SKUNumber);
contentItemData.SetValue(PRODUCT_VARIANT_SCHEMA_CODE_NAME, Uri.EscapeDataString(kx13Variant.SKUNumber ?? Guid.NewGuid().ToString()));
contentItemData.SetValue(PRODUCT_PRICE_SCHEMA_PRICE, kx13Variant.SKUPrice);
contentItemData.SetValue(AMOUNT_SCHEMA_NUMBER, kx13Variant.SKUWeight);
contentItemData.SetValue(AMOUNT_SCHEMA_UNIT, AMOUNT_UNIT_LB);
contentItemData.SetValue(SHIPPING_SCHEMA_WEIGHT, kx13Variant.SKUWeight);
contentItemData.SetValue(SHIPPING_SCHEMA_WEIGHT_UNIT, AMOUNT_UNIT_LB);
int newItemId = await contentItemManager.Create(createParams, contentItemData);
if (newItemId <= 0)
{
throw new Exception("Unable to create content item");
}
if (!await contentItemManager.TryPublish(newItemId, language))
{
throw new Exception($"Could not publish content item with ID {newItemId} in language {language}.");
}
var newItemGuid = CMS.ContentEngine.Internal.ContentItemInfo.Provider.Get(newItemId).ContentItemGUID;
if (newItemGuid == Guid.Empty)
{
throw new Exception("Unable to find guid for new content item");
}
return newItemGuid;
}
// ... existing code ...
At the end, your variant field mapping should look like this:
// Partial — see the complete CoffeeProductMapping.cs for all using directives
// ... existing code ...
m
.BuildField("ProductParentSchemaVariants")
.ConvertFrom(CLASS_NAME, COFFEE_ID_FIELD, false, (value, context) =>
{
EnsureInitialized();
var document = GetDocument(kx13Documents!, context);
int? skuId = (context as ConvertorTreeNodeContext)?.NodeSKUID;
if (skuId is null or <= 0)
{
return null;
}
var kx13Variants = GetKX13SkuVariants(kx13Skus!, skuId.Value);
if (!kx13Variants.Any())
{
return null;
}
string result = EnsureXbyKVariants(
kx13Variants: kx13Variants,
kx13Documents: kx13Documents!,
context: context,
contentQueryExecutor: contentQueryExecutor!,
contentItemManager: contentItemManager!).GetAwaiter().GetResult();
return result;
});
// ... existing code ...
Check coffee migration progress
Now that we’ve completed the file, it should look like this:
using CMS.ContentEngine;
using CMS.Core;
using CMS.DataEngine;
using CMS.FormEngine;
using CMS.Helpers;
using DancingGoatCore;
using Legacy;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.DependencyInjection;
using Migration.Tool.Common.Builders;
using Migration.Tool.Source;
using Migration.Tool.Source.Model;
namespace Migration.Tool.Extensions.ClassMappings;
public static class CoffeeProductMapping
{
// Constants
private const int ADMIN_ID = 53;
private const string PRODUCT_SCHEMA_NAME = "ProductSchemaName";
private const string PRODUCT_SKU_SCHEMA_SKU_CODE = "ProductSkuSchemaSkuCode";
private const string PRODUCT_VARIANT_SCHEMA_CODE_NAME = "ProductVariantSchemaCodeName";
private const string PRODUCT_PRICE_SCHEMA_PRICE = "ProductPriceSchemaPrice";
private const string AMOUNT_SCHEMA_NUMBER = "AmountSchemaNumber";
private const string AMOUNT_SCHEMA_UNIT = "AmountSchemaUnit";
private const string SHIPPING_SCHEMA_WEIGHT = "ProductShippingSchemaShippingWeight";
private const string SHIPPING_SCHEMA_WEIGHT_UNIT = "ProductShippingSchemaWeightUnit";
private const string AMOUNT_UNIT_LB = "lb";
private const string KENTICO_DEFAULT_WORKSPACE = "KenticoDefault";
private const string COFFEE_ID_FIELD = "CoffeeID";
private const string CLASS_NAME = "DancingGoatCore.Coffee";
// Cached KX13 data to optimize lookups during mapping
// Depending on the amount of data, you may want to implement a more sophisticated caching strategy or query data on demand
private static IEnumerable<IComSku>? kx13Skus;
private static IEnumerable<ICmsMetaFile>? skuMetaFiles;
private static IEnumerable<ICmsDocument>? kx13Documents;
// Services and facades
private static ModelFacade? modelFacade;
private static Common.ToolConfiguration? toolConfiguration;
private static IContentQueryExecutor? contentQueryExecutor;
private static IContentItemManager? contentItemManager;
public static IServiceCollection RemodelProducts(this IServiceCollection serviceCollection)
{
var m = new MultiClassMapping(CLASS_NAME, target =>
{
target.ClassName = CLASS_NAME;
target.ClassTableName = "DancingGoatCore_Coffee";
target.ClassDisplayName = "Coffee";
target.ClassType = ClassType.CONTENT_TYPE;
target.ClassContentTypeType = ClassContentTypeType.REUSABLE;
target.ClassWebPageHasUrl = false;
});
// set new primary key
m.BuildField("CoffeeID").AsPrimaryKey();
// Apply product schema and parent schema, as coffee has variants
m.UseReusableSchema("ProductSchema");
m.UseReusableSchema("ProductParentSchema");
//Translatable SKU fields, which can have translated values in the CMS_Document table in KX13
m
.BuildField("ProductSchemaName")
.ConvertFrom(CLASS_NAME, COFFEE_ID_FIELD, false, (value, context) =>
{
EnsureInitialized();
var document = GetDocument(kx13Documents!, context);
if (!string.IsNullOrWhiteSpace(document?.DocumentSKUName))
{
return document.DocumentSKUName;
}
int? skuId = (context as ConvertorTreeNodeContext)?.NodeSKUID;
if (skuId is not null and > 0)
{
return GetSkuValue(kx13Skus!, skuId.Value, sku => sku.SKUName);
}
return null;
});
m
.BuildField("ProductSchemaDescription")
.ConvertFrom(CLASS_NAME, COFFEE_ID_FIELD, false, (value, context) =>
{
EnsureInitialized();
var document = GetDocument(kx13Documents!, context);
var translatedLongDescription = document?.DocumentSKUDescription;
var translatedShortDescription = document?.DocumentSKUShortDescription;
var translatedDescription = !string.IsNullOrWhiteSpace(translatedLongDescription)
? translatedLongDescription
: translatedShortDescription;
if (!string.IsNullOrWhiteSpace(translatedDescription))
{
return translatedDescription;
}
int? skuId = (context as ConvertorTreeNodeContext)?.NodeSKUID;
if (!skuId.HasValue)
{
return null;
}
var sku = GetKX13Sku(kx13Skus!, skuId.Value);
var skuDescription = !string.IsNullOrWhiteSpace(sku?.SKUDescription)
? sku.SKUDescription
: sku?.SKUShortDescription;
return skuDescription;
});
// Complex field mappings that involve creating new content items and linking them
m
.BuildField("ProductSchemaImages")
.ConvertFrom(CLASS_NAME, COFFEE_ID_FIELD, true, (value, context) =>
{
EnsureInitialized();
int? skuId = (context as ConvertorTreeNodeContext)?.NodeSKUID;
if (skuId is null or <= 0)
{
return null;
}
var document = GetDocument(kx13Documents!, context);
var kx13Sku = GetKX13Sku(kx13Skus!, skuId.Value);
if (kx13Sku is null || document is null || string.IsNullOrWhiteSpace(kx13Sku.SKUImagePath))
{
return null;
}
string? fullImageUrl = GetFullMetaFileUrl(kx13Sku.SKUImagePath, toolConfiguration!);
if (string.IsNullOrWhiteSpace(fullImageUrl))
{
return null;
}
var metaFileGuid = EnsureMediaFileForSkuMetaFileImage(fullImageUrl, document, contentQueryExecutor!, contentItemManager!);
if (!metaFileGuid.HasValue)
{
return null;
}
// KX13 SKUs store a single path in SKUImagePath, so we don't need to worry about multiple images per SKU here, though multiples are supported in XbyK
return $"[{{\"Identifier\":\"{metaFileGuid.Value}\"}}]";
});
m
.BuildField("ProductParentSchemaVariants")
.ConvertFrom(CLASS_NAME, COFFEE_ID_FIELD, false, (value, context) =>
{
EnsureInitialized();
var document = GetDocument(kx13Documents!, context);
int? skuId = (context as ConvertorTreeNodeContext)?.NodeSKUID;
if (skuId is null or <= 0)
{
return null;
}
var kx13Variants = GetKX13SkuVariants(kx13Skus!, skuId.Value);
if (!kx13Variants.Any())
{
return null;
}
string result = EnsureXbyKVariants(
kx13Variants: kx13Variants,
kx13Documents: kx13Documents!,
context: context,
contentQueryExecutor: contentQueryExecutor!,
contentItemManager: contentItemManager!).GetAwaiter().GetResult();
return result;
});
// Standard one-to-one field mappings
m
.BuildField("CoffeeFarm")
.SetFrom(CLASS_NAME, "CoffeeFarm", true)
.WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Farm RM"));
m
.BuildField("CoffeeCountry")
.WithFieldPatch(f => f.Caption = "Country RM")
.SetFrom(CLASS_NAME, "CoffeeCountry", true);
m
.BuildField("CoffeeVariety")
.SetFrom(CLASS_NAME, "CoffeeVariety", true)
.WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Variety RM"));
m
.BuildField("CoffeeProcessing")
.SetFrom(CLASS_NAME, "CoffeeProcessing", true)
.WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Processing RM"));
m
.BuildField("CoffeeAltitude")
.SetFrom(CLASS_NAME, "CoffeeAltitude", true)
.WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Altitude RM"));
m
.BuildField("CoffeeIsDecaf")
.SetFrom(CLASS_NAME, "CoffeeIsDecaf", true)
.WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "IsDecaf RM"));
// register class mapping
serviceCollection.AddSingleton<IClassMapping>(m);
return serviceCollection;
}
// Initialize the services and cached data needed for the mapping.
private static void EnsureInitialized()
{
modelFacade ??= KsCoreDiExtensions.ServiceProvider.GetRequiredService<ModelFacade>();
toolConfiguration ??= KsCoreDiExtensions.ServiceProvider.GetRequiredService<Common.ToolConfiguration>();
contentQueryExecutor ??= Service.Resolve<IContentQueryExecutor>();
contentItemManager ??= Service.Resolve<IContentItemManagerFactory>().Create(ADMIN_ID);
kx13Skus ??= modelFacade.SelectAll<IComSku>();
skuMetaFiles ??= GetSkuMetaFiles(modelFacade, kx13Skus);
kx13Documents ??= GetKX13CoffeeDocuments(modelFacade);
}
// Access data from KX13
// Queries a single value from a KX13 table with `WHERE <column> = <value>` logic
private static T GetValueFromTable<T>(
ModelFacade modelFacade,
string tableName,
string columnName,
string whereColumnName,
object whereValue) =>
modelFacade.Select(
$"SELECT TOP 1 {columnName} FROM {tableName} WHERE {whereColumnName} = @whereValue",
(reader, _) => reader.IsDBNull(0) ? default : reader.GetFieldValue<T>(0),
new SqlParameter("whereValue", whereValue)
).FirstOrDefault()!;
// Retrieve all meta files associated with SKUs from KX13 database
private static IEnumerable<ICmsMetaFile> GetSkuMetaFiles(ModelFacade modelFacade, IEnumerable<IComSku> skus)
{
var metaFileGuids = GetSkuMetafileGuids(skus);
return modelFacade.Select<ICmsMetaFile>(
where: $"MetaFileGuid IN ('{string.Join("','", metaFileGuids)}')",
orderBy: "MetaFileID"
).ToList();
}
// Retrieve all coffee documents from KX13 database.
private static IEnumerable<ICmsDocument> GetKX13CoffeeDocuments(ModelFacade modelFacade)
{
int coffeeClassId = GetValueFromTable<int>(modelFacade,
tableName: "CMS_Class",
columnName: "ClassID",
whereColumnName: "ClassName",
whereValue: CLASS_NAME);
var nodeIds = modelFacade.Select(
$"SELECT NodeID FROM CMS_Tree WHERE NodeClassID = @coffeeClassId",
(reader, _) => reader.GetInt32(0),
// Cast value as object to avoid SqlParameter constructor ambiguity with int overload
new SqlParameter("coffeeClassId", (object)coffeeClassId)
).ToArray();
string nodeIdList = string.Join(",", nodeIds);
return modelFacade.Select<ICmsDocument>(
where: $"DocumentNodeID IN ({nodeIdList})",
orderBy: "DocumentID"
).ToList();
}
// Work with collections of KX13 data
// Retrieve all SKU variants for a given KX13 SKU.
private static IEnumerable<IComSku> GetKX13SkuVariants(IEnumerable<IComSku> allKx13Skus, int skuId) =>
allKx13Skus.Where(sku => sku.SKUParentSKUID == skuId);
// Gets a full URL to a KX13 metafile given its path, using the SourceInstanceUri from the Migration.Tool.CLI appsettings.json file.
private static string? GetFullMetaFileUrl(string path, Common.ToolConfiguration settings)
{
string relativePath = path.TrimStart('~');
var uri = settings.OptInFeatures?.QuerySourceInstanceApi?.Connections?.FirstOrDefault()?.SourceInstanceUri;
if (uri is null)
{
return null;
}
var uriBuilder = new UriBuilder(uri)
{
Path = relativePath
};
return uriBuilder.ToString();
}
// Retrieves a value from the KX13 SKU with the given ID based on the provided selector.
private static T? GetSkuValue<T>(IEnumerable<IComSku> skus, int skuId, Func<IComSku, T> selector) =>
skus.Where(sku => sku.SKUID == skuId)
.Select(selector)
.FirstOrDefault();
// Retrieves a value from the KX13 metafile with the given GUID based on the provided selector.
private static T? GetMetaFileValue<T>(IEnumerable<ICmsMetaFile> metaFiles, Guid metaFileGuid, Func<ICmsMetaFile, T> selector) =>
metaFiles.Where(mf => mf.MetaFileGUID == metaFileGuid)
.Select(selector)
.FirstOrDefault();
// Finds the KX13 SKU with the provided ID.
private static IComSku? GetKX13Sku(IEnumerable<IComSku> skus, int skuId) =>
skus.FirstOrDefault(sku => sku.SKUID == skuId);
// Finds the KX13 document associated with the content item's ConvertorTreeNodeContext.
private static ICmsDocument? GetDocument(IEnumerable<ICmsDocument> documents, IConvertorContext context)
{
int documentId = (context as ConvertorTreeNodeContext)?.DocumentId ?? 0;
return documents.FirstOrDefault(doc => doc.DocumentID == documentId);
}
// Language utilities
// Maps KX13 culture codes to XbyK language code names.
private static string GetLanguageFromCultureCode(string? cultureCode) => cultureCode switch
{
// Language code names taken from the XbyK database
"es-ES" => "es-ES",
"en-US" or _ => "en"
};
// Returns the XbyK content language ID for the given language name, or null if not found.
private static int? GetContentLanguageId(string languageName)
{
var contentLanguageInfoProvider = Provider<ContentLanguageInfo>.Instance;
var languageInfo = contentLanguageInfoProvider.Get(languageName);
return languageInfo?.ContentLanguageID;
}
// Generic XbyK content item retrieval
// Executes a content item query for the given content type and returns mapped results.
private static IEnumerable<T> GetContentItems<T>(string contentTypeName, Func<ContentTypeQueryParameters, ContentTypeQueryParameters> queryConfiguration, IContentQueryExecutor executor) where T : IContentItemFieldsSource
{
var builder = new ContentItemQueryBuilder();
builder.ForContentType(contentTypeName, q => queryConfiguration(q));
var contentItems = executor.GetMappedResult<T>(builder,
new ContentQueryExecutionOptions() { ForPreview = true })
.GetAwaiter().GetResult();
return contentItems;
}
// Image file utilities
// Queries existing XbyK media file content items by code name.
private static IEnumerable<MediaFile> GetExistingMediaFiles(string codeName, IContentQueryExecutor contentQueryExecutor) =>
GetContentItems<MediaFile>(MediaFile.CONTENT_TYPE_NAME, subqueryConfiguration =>
subqueryConfiguration.Where(where => where
.WhereEquals(nameof(ContentItemFields.ContentItemName), codeName)), contentQueryExecutor);
// Extracts meta file GUIDs from all SKUs that have an image path.
private static IEnumerable<Guid> GetSkuMetafileGuids(IEnumerable<IComSku> skus) =>
skus.Where(sku => !string.IsNullOrWhiteSpace(sku.SKUImagePath))
.Select(GetMetaFileGuidFromSku)
.Where(guid => guid.HasValue)
.Select(guid => guid!.Value);
// Extracts the meta file GUID from a SKU's image path, if present.
private static Guid? GetMetaFileGuidFromSku(IComSku sku)
{
if (string.IsNullOrWhiteSpace(sku.SKUImagePath))
{
return null;
}
return GetMetaFileGuidFromPath(sku.SKUImagePath);
}
// Parses a metafile GUID from a KX13 metafile URL path (e.g. ~/getmetafile/{guid}/filename).
private static Guid? GetMetaFileGuidFromPath(string kx13SkuMetaFilePath)
{
// In Dancing Goat, SKUImagePath uses meta file URLs with the format
// ~/getmetafile/01a4223b-fc43-4635-8658-878a7539b8c5/cup-personalized-small
var urlParts = kx13SkuMetaFilePath.Trim().Split('/');
string guidSegment = urlParts[^2];
if (Guid.TryParse(guidSegment, out Guid metaFileGuid))
{
return metaFileGuid;
}
return null;
}
// Performs a synchronous HTTP GET request and returns the response.
// Used for retrieving meta file from KX13
private static HttpResponseMessage GetHttpResponse(string url)
{
using var httpClient = new HttpClient();
return httpClient.GetAsync(url)
.GetAwaiter().GetResult();
}
// Determines the file extension from the HTTP response's Content-Type header.
private static string GetMetafileExtension(HttpResponseMessage response)
{
// Determine extension from Content-Type header (e.g., "image/jpeg" -> ".jpeg")
string contentType = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream";
return contentType switch
{
"image/jpeg" => ".jpg",
"image/png" => ".png",
"image/gif" => ".gif",
"image/webp" => ".webp",
"image/bmp" => ".bmp",
"image/svg+xml" => ".svg",
"image/tiff" => ".tiff",
_ => ".bin"
};
}
// Downloads a KX13 SKU metafile image and ensures a corresponding XbyK media file content item exists, creating or adding a language version as needed.
private static Guid? EnsureMediaFileForSkuMetaFileImage(string kx13SkuMetaFilePath, ICmsDocument? document, IContentQueryExecutor contentQueryExecutor, IContentItemManager contentItemManager)
{
string language = GetLanguageFromCultureCode(document?.DocumentCulture);
var metaFileGuid = GetMetaFileGuidFromPath(kx13SkuMetaFilePath);
if (metaFileGuid is null)
{
return null;
}
var response = GetHttpResponse(kx13SkuMetaFilePath);
// Read into a MemoryStream so we know the size
var memoryStream = new MemoryStream();
response.Content.CopyToAsync(memoryStream).GetAwaiter().GetResult();
memoryStream.Position = 0;
string metaFileExtension = GetMetafileExtension(response);
// In Dancing Goat, SKUImagePath uses meta file URLs with the format
// ~/getmetafile/01a4223b-fc43-4635-8658-878a7539b8c5/cup-personalized-small
var urlParts = kx13SkuMetaFilePath.Trim().Split('/');
string metaFileName = $"{urlParts[^1]}{metaFileExtension}";
string? oldMetaFileTitle = GetMetaFileValue(skuMetaFiles!, metaFileGuid.Value, mf => mf.MetaFileTitle);
string mediaFileTitle = !string.IsNullOrWhiteSpace(oldMetaFileTitle) ? oldMetaFileTitle : metaFileName;
string mediaFileCodeName = ValidationHelper.GetCodeName(mediaFileTitle);
var existingMediaFiles = GetExistingMediaFiles(mediaFileCodeName, contentQueryExecutor);
int languageId = GetContentLanguageId(language) ?? 0;
// If there are existing variants with the given SKU code, but not in the current language, add a language version
// If you do not plan to have any language-specific product images in the future, consider skipping this step
// Instead, create a single media file in the default language only, and use language fallbacks
if (existingMediaFiles.Any() && !existingMediaFiles.Any(v => v.SystemFields.ContentItemCommonDataContentLanguageID == languageId))
{
var mediaFileItemId = existingMediaFiles.First().SystemFields.ContentItemID;
var mediaFileItemGuid = existingMediaFiles.First().SystemFields.ContentItemGUID;
return AddMediaFileLanguageVersion(
metaFileName: metaFileName,
metaFileExtension: metaFileExtension,
metaFileGuid: metaFileGuid.Value,
memoryStream: memoryStream,
mediaFileItemId: mediaFileItemId,
mediaFileItemGuid: mediaFileItemGuid,
mediaFileTitle: mediaFileTitle,
language: language,
contentItemManager: contentItemManager);
}
else if (!existingMediaFiles.Any())
{
return CreateMediaFile(
metaFileName: metaFileName,
metaFileExtension: metaFileExtension,
metaFileGuid: metaFileGuid.Value,
memoryStream: memoryStream,
mediaFileTitle: mediaFileTitle,
mediaFileCodeName: mediaFileCodeName,
language: language,
contentItemManager: contentItemManager);
}
return existingMediaFiles.First().SystemFields.ContentItemGUID;
}
// Creates a new XbyK media file content item from the provided meta file data.
private static Guid? CreateMediaFile(string metaFileName,
string metaFileExtension,
Guid metaFileGuid,
MemoryStream memoryStream,
string mediaFileTitle,
string mediaFileCodeName,
string language,
IContentItemManager contentItemManager)
{
var assetMetadata = new ContentItemAssetMetadata
{
Extension = metaFileExtension,
Identifier = metaFileGuid,
LastModified = DateTime.Now,
Name = metaFileName,
Size = memoryStream.Length
};
var fileSource = new ContentItemAssetStreamSource(
(CancellationToken ct) => Task.FromResult<Stream>(memoryStream));
var assetWithSource = new ContentItemAssetMetadataWithSource(fileSource, assetMetadata);
var createParams = new CreateContentItemParameters(
MediaFile.CONTENT_TYPE_NAME,
mediaFileCodeName,
mediaFileTitle,
language,
KENTICO_DEFAULT_WORKSPACE)
{
IsSecured = false
};
// Use assetWithSource in ContentItemData when creating/updating the content item
var itemData = new ContentItemData(new Dictionary<string, object>
{
{ nameof(MediaFile.LegacyMediaFileAsset), assetWithSource },
{ nameof(MediaFile.LegacyMediaFileTitle), mediaFileTitle }
});
// Create the content item in the database
int newId = contentItemManager.Create(createParams, itemData).GetAwaiter().GetResult();
if (newId <= 0)
{
throw new Exception($"Failed to create media file content item for meta file {metaFileName} ({metaFileGuid}).");
}
if (!contentItemManager.TryPublish(newId, language).GetAwaiter().GetResult())
{
throw new Exception($"Could not publish media file content item with ID {newId}.");
}
Guid? newGuid = CMS.ContentEngine.Internal.ContentItemInfo.Provider.Get(newId)?.ContentItemGUID;
return newGuid;
}
// Adds a language version to an existing XbyK media file content item.
private static Guid? AddMediaFileLanguageVersion(string metaFileName,
string metaFileExtension,
Guid metaFileGuid,
MemoryStream memoryStream,
int mediaFileItemId,
Guid mediaFileItemGuid,
string mediaFileTitle,
string language,
IContentItemManager contentItemManager)
{
var assetMetadata = new ContentItemAssetMetadata
{
Extension = metaFileExtension,
Identifier = metaFileGuid,
LastModified = DateTime.Now,
Name = metaFileName,
Size = memoryStream.Length
};
var fileSource = new ContentItemAssetStreamSource(
(CancellationToken ct) => Task.FromResult<Stream>(memoryStream));
var assetWithSource = new ContentItemAssetMetadataWithSource(fileSource, assetMetadata);
var languageVariantParams = new CreateLanguageVariantParameters(mediaFileItemId,
mediaFileTitle,
language);
var contentItemData = new ContentItemData(new Dictionary<string, object>
{
{ nameof(MediaFile.LegacyMediaFileAsset), assetWithSource },
{ nameof(MediaFile.LegacyMediaFileTitle), mediaFileTitle }
});
if (!contentItemManager.TryCreateLanguageVariant(languageVariantParams, contentItemData).GetAwaiter().GetResult())
{
throw new Exception($"Unable to create language variant for existing media file content item with ID {mediaFileItemId} in language {language}.");
}
if (!contentItemManager.TryPublish(mediaFileItemId, language).GetAwaiter().GetResult())
{
throw new Exception($"Could not publish media file content item with ID {mediaFileItemId}.");
}
return mediaFileItemGuid;
}
// Utilities for XbyK variants
// Queries existing XbyK content items of the given type whose SKU code matches any of the provided KX13 variants.
private static IEnumerable<T> GetExistingXbyKVariants<T>(IEnumerable<IComSku> kx13Variants, string variantContentTypeName, IContentQueryExecutor contentQueryExecutor)
where T : IContentItemFieldsSource =>
GetContentItems<T>(variantContentTypeName, subqueryConfiguration =>
subqueryConfiguration.Where(where => where
.WhereIn(nameof(IProductSkuSchema.ProductSkuSchemaSkuCode), kx13Variants.Select(v => v.SKUNumber))), contentQueryExecutor);
// Ensures all KX13 variants exist as XbyK content items (creating or adding language versions as needed) and returns a JSON array of content item references.
private static async Task<string> EnsureXbyKVariants(IEnumerable<IComSku> kx13Variants, IEnumerable<ICmsDocument> kx13Documents, IConvertorContext context, IContentQueryExecutor contentQueryExecutor, IContentItemManager contentItemManager)
{
var document = GetDocument(kx13Documents, context);
string language = GetLanguageFromCultureCode(document?.DocumentCulture);
if (!kx13Variants.Any())
{
return string.Empty;// new List<ContentItemReference>();
}
var xByKVariants = GetExistingXbyKVariants<CoffeeVariant>(kx13Variants, CoffeeVariant.CONTENT_TYPE_NAME, contentQueryExecutor);
int languageId = GetContentLanguageId(language) ?? 0;
var createdVariants = new List<Guid>();
foreach (var kx13Variant in kx13Variants)
{
var existingVariants = xByKVariants.Where(v =>
string.Equals(v.ProductSkuSchemaSkuCode, kx13Variant.SKUNumber, StringComparison.InvariantCultureIgnoreCase));
// If there are existing variants with the given SKU code, but not in the current language, add a language version
if (existingVariants.Any() && !existingVariants.Any(v => v.SystemFields.ContentItemCommonDataContentLanguageID == languageId))
{
// KX13 does not support multilingual translations for variants, but XbyK does
// If you do not plan to expand variants with language-specific data in the future, consider skipping this step
// Instead, create variants only in the default language and enable language fallbacks
var itemGuid = await AddVariantLanguageVersion(
existingVariants: existingVariants,
kx13Variant: kx13Variant,
language: language,
languageId: languageId,
contentItemManager: contentItemManager);
createdVariants.Add(itemGuid);
}
// If there are no existing product variants with the given SKU code, create a new content item
else if (!existingVariants.Any())
{
var newItemGuid = await CreateVariant(kx13Variant, language, contentItemManager);
createdVariants.Add(newItemGuid);
}
// If there are existing variants in the current language, reference them.
else
{
createdVariants.AddRange(existingVariants.Select(v => v.SystemFields.ContentItemGUID));
}
}
string elements = string.Join(", ", createdVariants.Select(itemGuid => $"{{\"Identifier\":\"{itemGuid}\"}}").Distinct());
string result = $"[{elements}]";
return result;
}
// Add a language version for an existing product variant in XbyK in the given language
private static async Task<Guid> AddVariantLanguageVersion(IEnumerable<CoffeeVariant> existingVariants, IComSku kx13Variant, string language, int languageId, IContentItemManager contentItemManager)
{
var itemId = existingVariants.First().SystemFields.ContentItemID;
var itemGuid = existingVariants.First().SystemFields.ContentItemGUID;
var languageVariantParams = new CreateLanguageVariantParameters(itemId,
kx13Variant.SKUName,
language);
var contentItemData = new ContentItemData();
// These fields are NOT translated across languages like those with document equivalents (DocumentSKUName, DocumentSKUDescription, etc.)
// KX13 variants do not correspond to documents, so there is no translation for them
contentItemData.SetValue(PRODUCT_SCHEMA_NAME, kx13Variant.SKUName);
contentItemData.SetValue(PRODUCT_SKU_SCHEMA_SKU_CODE, kx13Variant.SKUNumber);
contentItemData.SetValue(PRODUCT_VARIANT_SCHEMA_CODE_NAME, Uri.EscapeDataString(kx13Variant.SKUNumber ?? Guid.NewGuid().ToString()));
contentItemData.SetValue(PRODUCT_PRICE_SCHEMA_PRICE, kx13Variant.SKUPrice);
contentItemData.SetValue(AMOUNT_SCHEMA_NUMBER, kx13Variant.SKUWeight);
contentItemData.SetValue(AMOUNT_SCHEMA_UNIT, AMOUNT_UNIT_LB);
contentItemData.SetValue(SHIPPING_SCHEMA_WEIGHT, kx13Variant.SKUWeight);
contentItemData.SetValue(SHIPPING_SCHEMA_WEIGHT_UNIT, AMOUNT_UNIT_LB);
if (!await contentItemManager.TryCreateLanguageVariant(languageVariantParams, contentItemData))
{
var existingLanguages = existingVariants.Select(v => v.SystemFields.ContentItemCommonDataContentLanguageID);
throw new Exception($"Unable to create language variant for existing content item with ID {itemId} in language {language} ({languageId}). Existing languages: {string.Join(", ", existingLanguages)}.");
}
if (!await contentItemManager.TryPublish(itemId, language))
{
throw new Exception($"Could not publish content item with ID {itemId} in language {language} ({languageId}).");
}
return itemGuid;
}
// Create a product variant in the provided language in XbyK based on a KX13 variant, and return the new content item's GUID
private static async Task<Guid> CreateVariant(IComSku kx13Variant, string language, IContentItemManager contentItemManager)
{
var createParams = new CreateContentItemParameters(
CoffeeVariant.CONTENT_TYPE_NAME,
null,
kx13Variant.SKUName,
language,
KENTICO_DEFAULT_WORKSPACE)
{
IsSecured = false
};
var contentItemData = new ContentItemData();
contentItemData.SetValue(PRODUCT_SCHEMA_NAME, kx13Variant.SKUName);
contentItemData.SetValue(PRODUCT_SKU_SCHEMA_SKU_CODE, kx13Variant.SKUNumber);
contentItemData.SetValue(PRODUCT_VARIANT_SCHEMA_CODE_NAME, Uri.EscapeDataString(kx13Variant.SKUNumber ?? Guid.NewGuid().ToString()));
contentItemData.SetValue(PRODUCT_PRICE_SCHEMA_PRICE, kx13Variant.SKUPrice);
contentItemData.SetValue(AMOUNT_SCHEMA_NUMBER, kx13Variant.SKUWeight);
contentItemData.SetValue(AMOUNT_SCHEMA_UNIT, AMOUNT_UNIT_LB);
contentItemData.SetValue(SHIPPING_SCHEMA_WEIGHT, kx13Variant.SKUWeight);
contentItemData.SetValue(SHIPPING_SCHEMA_WEIGHT_UNIT, AMOUNT_UNIT_LB);
int newItemId = await contentItemManager.Create(createParams, contentItemData);
if (newItemId <= 0)
{
throw new Exception("Unable to create content item");
}
if (!await contentItemManager.TryPublish(newItemId, language))
{
throw new Exception($"Could not publish content item with ID {newItemId} in language {language}.");
}
var newItemGuid = CMS.ContentEngine.Internal.ContentItemInfo.Provider.Get(newItemId).ContentItemGUID;
if (newItemGuid == Guid.Empty)
{
throw new Exception("Unable to find guid for new content item");
}
return newItemGuid;
}
}
If you add reusable field schemas to your target instance another way, include definitions for required schemas in your second class mapping (ProductSchema and ProductParentSchema in this example) in order to assign them to your product types via UseReusableSchema.
Register the coffee migration in ServiceCollectionExtensions
After setup mappings and model support are in place, register the main remodeling mapping.
using Migration.Tool.Extensions.ClassMappings;
// ... existing code ...
public static IServiceCollection UseCustomizations(this IServiceCollection services)
{
// ... existing code ...
services.PrepareProductTypes();
services.RemodelProducts();
// ... existing code ...
return services;
}
// ... existing code ...
Build and run the migration tool (second pass)
Now we can run the SKU migration.
- Make sure your KX13 instance is running during migration so the tool can access SKU metafiles based on their URLs.
- Ensure your target XbyK instance is not running.
Then build the Migration.Tool.CLI project and run it again.
dotnet build
.\bin\Debug\net8.0\Migration.Tool.CLI.exe migrate --sites --users --page-types --pages --custom-modules --media-libraries
Verify the results in XbyK
With the migration finished, you can log into Xperience by Kentico and see the coffee products, including their images and variants in the Content hub:
If you added language versions of any of the coffee pages in KX13, you will see them in XbyK as well:

If the page type you mapped to CoffeeVariant had any pages, remember to delete the resulting content items in XbyK. For example:
Troubleshooting
- If the mapping cannot resolve source metafile URLs, verify
SourceInstanceUriand API discovery configuration in migration tool settings. - If schema fields are missing in target types, ensure
PrepareProductTypes()runs beforeRemodelProducts(). - If media files or variants are duplicated unexpectedly, verify code name and SKU code uniqueness assumptions in source data.
Related resources
- Custom class mappings (IClassMapping)
- Migration command parameters
- Remodel page types as reusable field schemas
- Migrate widget data as reusable content
What’s next?
Once your SKU migration flow is stable, you can combine this approach with other deep-dive scenarios.
If you encounter any roadblocks during your own migration, or if you have ideas for a subject we haven’t covered, don’t hesitate to reach out to us through the Send us feedback button at the bottom of this page.