diff --git a/KVA/Migration.Toolkit.Source/Contexts/SourceObjectContext.cs b/KVA/Migration.Toolkit.Source/Contexts/SourceObjectContext.cs new file mode 100644 index 00000000..e9135057 --- /dev/null +++ b/KVA/Migration.Toolkit.Source/Contexts/SourceObjectContext.cs @@ -0,0 +1,7 @@ +using CMS.FormEngine; +using Migration.Toolkit.KXP.Api.Services.CmsClass; +using Migration.Toolkit.Source.Model; + +namespace Migration.Toolkit.Source.Contexts; + +public record DocumentSourceObjectContext(ICmsTree CmsTree, ICmsClass NodeClass, ICmsSite Site, FormInfo OldFormInfo, FormInfo NewFormInfo, int? DocumentId) : ISourceObjectContext; diff --git a/KVA/Migration.Toolkit.Source/Handlers/MigratePageTypesCommandHandler.cs b/KVA/Migration.Toolkit.Source/Handlers/MigratePageTypesCommandHandler.cs index fda271bb..089c4d08 100644 --- a/KVA/Migration.Toolkit.Source/Handlers/MigratePageTypesCommandHandler.cs +++ b/KVA/Migration.Toolkit.Source/Handlers/MigratePageTypesCommandHandler.cs @@ -1,6 +1,6 @@ using CMS.ContentEngine; using CMS.DataEngine; - +using CMS.FormEngine; using MediatR; using Microsoft.Data.SqlClient; @@ -8,12 +8,15 @@ using Migration.Toolkit.Common; using Migration.Toolkit.Common.Abstractions; +using Migration.Toolkit.Common.Builders; using Migration.Toolkit.Common.Helpers; using Migration.Toolkit.Common.MigrationProtocol; using Migration.Toolkit.KXP.Api; +using Migration.Toolkit.KXP.Api.Services.CmsClass; using Migration.Toolkit.KXP.Models; using Migration.Toolkit.Source.Contexts; using Migration.Toolkit.Source.Helpers; +using Migration.Toolkit.Source.Mappers; using Migration.Toolkit.Source.Model; using Migration.Toolkit.Source.Services; @@ -28,8 +31,11 @@ public class MigratePageTypesCommandHandler( ToolkitConfiguration toolkitConfiguration, ModelFacade modelFacade, PageTemplateMigrator pageTemplateMigrator, - ReusableSchemaService reusableSchemaService -) + ReusableSchemaService reusableSchemaService, + IEnumerable classMappings, + IFieldMigrationService fieldMigrationService, + IEnumerable reusableSchemaBuilders + ) : IRequestHandler { private const string CLASS_CMS_ROOT = "CMS.Root"; @@ -43,10 +49,140 @@ public async Task Handle(MigratePageTypesCommand request, Cancell .OrderBy(x => x.ClassID) ); + ExecReusableSchemaBuilders(); + + var manualMappings = new Dictionary(); + foreach (var classMapping in classMappings) + { + var newDt = DataClassInfoProvider.GetDataClassInfo(classMapping.TargetClassName) ?? DataClassInfo.New(); + classMapping.PatchTargetDataClass(newDt); + + // might not need ClassGUID + // newDt.ClassGUID = GuidHelper.CreateDataClassGuid($"{newDt.ClassName}|{newDt.ClassTableName}"); + + var cmsClasses = new List(); + foreach (string sourceClassName in classMapping.SourceClassNames) + { + cmsClasses.AddRange(modelFacade.SelectWhere("ClassName=@className", new SqlParameter("className", sourceClassName))); + } + + var nfi = string.IsNullOrWhiteSpace(newDt.ClassFormDefinition) ? new FormInfo() : new FormInfo(newDt.ClassFormDefinition); + bool hasPrimaryKey = false; + foreach (var formFieldInfo in nfi.GetFields(true, true, true, true, false)) + { + if (formFieldInfo.PrimaryKey) + { + hasPrimaryKey = true; + } + } + + if (!hasPrimaryKey) + { + if (string.IsNullOrWhiteSpace(classMapping.PrimaryKey)) + { + throw new InvalidOperationException($"Class mapping has no primary key set"); + } + else + { + var prototype = FormHelper.GetBasicFormDefinition(classMapping.PrimaryKey); + nfi.AddFormItem(prototype.GetFormField(classMapping.PrimaryKey)); + } + } + + newDt.ClassFormDefinition = nfi.GetXmlDefinition(); + + foreach (string schemaName in classMapping.ReusableSchemaNames) + { + reusableSchemaService.AddReusableSchemaToDataClass(newDt, schemaName); + } + + nfi = new FormInfo(newDt.ClassFormDefinition); + + var fieldInReusableSchemas = reusableSchemaService.GetFieldsFromReusableSchema(newDt).ToDictionary(x => x.Name, x => x); + + bool hasFieldsAlready = true; + foreach (var cmml in classMapping.Mappings.Where(m => m.IsTemplate).ToLookup(x => x.SourceFieldName)) + { + var cmm = cmml.FirstOrDefault() ?? throw new InvalidOperationException(); + if (fieldInReusableSchemas.ContainsKey(cmm.TargetFieldName)) + { + // part of reusable schema + continue; + } + + var sc = cmsClasses.FirstOrDefault(sc => sc.ClassName.Equals(cmm.SourceClassName, StringComparison.InvariantCultureIgnoreCase)) + ?? throw new NullReferenceException($"The source class '{cmm.SourceClassName}' does not exist - wrong mapping {classMapping}"); + + var fi = new FormInfo(sc.ClassFormDefinition); + if (nfi.GetFormField(cmm.TargetFieldName) is { }) + { + } + else + { + var src = fi.GetFormField(cmm.SourceFieldName); + src.Name = cmm.TargetFieldName; + nfi.AddFormItem(src); + hasFieldsAlready = false; + } + } + + if (!hasFieldsAlready) + { + FormDefinitionHelper.MapFormDefinitionFields(logger, fieldMigrationService, nfi.GetXmlDefinition(), false, true, newDt, false, false); + CmsClassMapper.PatchDataClassInfo(newDt, out _, out _); + } + + if (classMapping.TargetFieldPatchers.Count > 0) + { + nfi = new FormInfo(newDt.ClassFormDefinition); + foreach (string fieldName in classMapping.TargetFieldPatchers.Keys) + { + classMapping.TargetFieldPatchers[fieldName].Invoke(nfi.GetFormField(fieldName)); + } + + newDt.ClassFormDefinition = nfi.GetXmlDefinition(); + } + + DataClassInfoProvider.SetDataClassInfo(newDt); + foreach (var gByClass in classMapping.Mappings.GroupBy(x => x.SourceClassName)) + { + manualMappings.TryAdd(gByClass.Key, newDt); + } + + foreach (string sourceClassName in classMapping.SourceClassNames) + { + var sourceClass = cmsClasses.First(c => c.ClassName.Equals(sourceClassName, StringComparison.InvariantCultureIgnoreCase)); + foreach (var cmsClassSite in modelFacade.SelectWhere("ClassId = @classId", new SqlParameter("classId", sourceClass.ClassID))) + { + if (modelFacade.SelectById(cmsClassSite.SiteID) is { SiteGUID: var siteGuid }) + { + if (ChannelInfoProvider.ProviderObject.Get(siteGuid) is { ChannelID: var channelId }) + { + var info = new ContentTypeChannelInfo { ContentTypeChannelChannelID = channelId, ContentTypeChannelContentTypeID = newDt.ClassID }; + ContentTypeChannelInfoProvider.ProviderObject.Set(info); + } + else + { + logger.LogWarning("Channel for site with SiteGUID '{SiteGuid}' not found", siteGuid); + } + } + else + { + logger.LogWarning("Source site with SiteID '{SiteId}' not found", cmsClassSite.SiteID); + } + } + } + } + while (ksClasses.GetNext(out var di)) { var (_, ksClass) = di; + if (manualMappings.ContainsKey(ksClass.ClassName)) + { + continue; + } + if (ksClass.ClassInheritsFromClassID is { } classInheritsFromClassId && !primaryKeyMappingContext.HasMapping(c => c.ClassID, classInheritsFromClassId)) { // defer migration to later stage @@ -130,6 +266,61 @@ public async Task Handle(MigratePageTypesCommand request, Cancell return new GenericCommandResult(); } + private void ExecReusableSchemaBuilders() + { + foreach (var reusableSchemaBuilder in reusableSchemaBuilders) + { + reusableSchemaBuilder.AssertIsValid(); + var fieldInfos = reusableSchemaBuilder.FieldBuilders.Select(fb => + { + switch (fb) + { + case { Factory: { } factory }: + { + return factory(); + } + case { SourceFieldIdentifier: { } fieldIdentifier }: + { + var sourceClass = modelFacade.SelectWhere("ClassName=@className", new SqlParameter("className", fieldIdentifier.ClassName)).SingleOrDefault() + ?? throw new InvalidOperationException($"Invalid reusable schema field builder for field '{fieldIdentifier.ClassName}': DataClass not found, class name '{fieldIdentifier.ClassName}'"); + + if (string.IsNullOrWhiteSpace(sourceClass.ClassFormDefinition)) + { + throw new InvalidOperationException($"Invalid reusable schema field builder for field '{fieldIdentifier.ClassName}': Class '{fieldIdentifier.ClassName}' is missing field '{fieldIdentifier.FieldName}'"); + } + + // this might be cached as optimization + var patcher = new FormDefinitionPatcher( + logger, + sourceClass.ClassFormDefinition, + fieldMigrationService, + sourceClass.ClassIsForm.GetValueOrDefault(false), + sourceClass.ClassIsDocumentType, + true, + false + ); + + patcher.PatchFields(); + patcher.RemoveCategories(); + + var fi = new FormInfo(patcher.GetPatched()); + return fi.GetFormField(fieldIdentifier.FieldName) switch + { + { } field => field, + _ => throw new InvalidOperationException($"Invalid reusable schema field builder for field '{fieldIdentifier.ClassName}': Class '{fieldIdentifier.ClassName}' is missing field '{fieldIdentifier.FieldName}'") + }; + } + default: + { + throw new InvalidOperationException($"Invalid reusable schema field builder for field '{fb.TargetFieldName}'"); + } + } + }); + + reusableSchemaService.EnsureReusableFieldSchema(reusableSchemaBuilder.SchemaName, reusableSchemaBuilder.SchemaDisplayName, reusableSchemaBuilder.SchemaDescription, fieldInfos.ToArray()); + } + } + private async Task MigratePageTemplateConfigurations() { if (modelFacade.IsAvailable()) diff --git a/KVA/Migration.Toolkit.Source/Handlers/MigratePagesCommandHandler.cs b/KVA/Migration.Toolkit.Source/Handlers/MigratePagesCommandHandler.cs index 9fef20fa..9d58744e 100644 --- a/KVA/Migration.Toolkit.Source/Handlers/MigratePagesCommandHandler.cs +++ b/KVA/Migration.Toolkit.Source/Handlers/MigratePagesCommandHandler.cs @@ -45,7 +45,8 @@ public class MigratePagesCommandHandler( ModelFacade modelFacade, DeferredPathService deferredPathService, SpoiledGuidContext spoiledGuidContext, - SourceInstanceContext sourceInstanceContext + SourceInstanceContext sourceInstanceContext, + ClassMappingProvider classMappingProvider ) : IRequestHandler { @@ -194,7 +195,11 @@ public async Task Handle(MigratePagesCommand request, Cancellatio ? (Guid?)null : spoiledGuidContext.EnsureNodeGuid(ksNodeParent); - var targetClass = DataClassInfoProvider.ProviderObject.Get(ksNodeClass.ClassGUID); + DataClassInfo targetClass = null!; + var classMapping = classMappingProvider.GetMapping(ksNodeClass.ClassName); + targetClass = classMapping != null + ? DataClassInfoProvider.ProviderObject.Get(classMapping.TargetClassName) + : DataClassInfoProvider.ProviderObject.Get(ksNodeClass.ClassGUID); var results = mapper.Map(new CmsTreeMapperSource( ksNode, diff --git a/KVA/Migration.Toolkit.Source/Helpers/FormDefinitionHelper.cs b/KVA/Migration.Toolkit.Source/Helpers/FormDefinitionHelper.cs new file mode 100644 index 00000000..7e6f09ad --- /dev/null +++ b/KVA/Migration.Toolkit.Source/Helpers/FormDefinitionHelper.cs @@ -0,0 +1,76 @@ +using CMS.DataEngine; +using CMS.FormEngine; +using Microsoft.Extensions.Logging; +using Migration.Toolkit.KXP.Api.Services.CmsClass; +using Migration.Toolkit.Source.Model; + +namespace Migration.Toolkit.Source.Helpers; + +public static class FormDefinitionHelper +{ + public static void MapFormDefinitionFields(ILogger logger, IFieldMigrationService fieldMigrationService, ICmsClass source, DataClassInfo target, bool isCustomizableSystemClass, bool classIsCustom) + { + if (!string.IsNullOrWhiteSpace(source.ClassFormDefinition)) + { + var patcher = new FormDefinitionPatcher( + logger, + source.ClassFormDefinition, + fieldMigrationService, + source.ClassIsForm.GetValueOrDefault(false), + source.ClassIsDocumentType, + isCustomizableSystemClass, + classIsCustom + ); + + patcher.PatchFields(); + patcher.RemoveCategories(); // TODO tk: 2022-10-11 remove when supported + + string? result = patcher.GetPatched(); + if (isCustomizableSystemClass) + { + result = FormHelper.MergeFormDefinitions(target.ClassFormDefinition, result); + } + + var formInfo = new FormInfo(result); + target.ClassFormDefinition = formInfo.GetXmlDefinition(); + } + else + { + target.ClassFormDefinition = new FormInfo().GetXmlDefinition(); + } + } + + public static void MapFormDefinitionFields(ILogger logger, IFieldMigrationService fieldMigrationService, + string sourceClassDefinition, bool? classIsForm, bool classIsDocumentType, + DataClassInfo target, bool isCustomizableSystemClass, bool classIsCustom) + { + if (!string.IsNullOrWhiteSpace(sourceClassDefinition)) + { + var patcher = new FormDefinitionPatcher( + logger, + sourceClassDefinition, + fieldMigrationService, + classIsForm.GetValueOrDefault(false), + classIsDocumentType, + isCustomizableSystemClass, + classIsCustom + ); + + patcher.PatchFields(); + patcher.RemoveCategories(); // TODO tk: 2022-10-11 remove when supported + + string? result = patcher.GetPatched(); + if (isCustomizableSystemClass) + { + result = FormHelper.MergeFormDefinitions(target.ClassFormDefinition, result); + } + + var formInfo = new FormInfo(result); + target.ClassFormDefinition = formInfo.GetXmlDefinition(); + } + else + { + target.ClassFormDefinition = new FormInfo().GetXmlDefinition(); + } + } +} diff --git a/KVA/Migration.Toolkit.Source/KsCoreDiExtensions.cs b/KVA/Migration.Toolkit.Source/KsCoreDiExtensions.cs index 943d51d3..00937c87 100644 --- a/KVA/Migration.Toolkit.Source/KsCoreDiExtensions.cs +++ b/KVA/Migration.Toolkit.Source/KsCoreDiExtensions.cs @@ -25,6 +25,7 @@ using Migration.Toolkit.Source.Helpers; using Migration.Toolkit.Source.Mappers; using Migration.Toolkit.Source.Model; +using Migration.Toolkit.Source.Providers; using Migration.Toolkit.Source.Services; namespace Migration.Toolkit.Source; @@ -52,6 +53,7 @@ public static IServiceCollection UseKsToolkitCore(this IServiceCollection servic services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); diff --git a/KVA/Migration.Toolkit.Source/Mappers/CmsClassMapper.cs b/KVA/Migration.Toolkit.Source/Mappers/CmsClassMapper.cs index bbd1df72..06eb7e85 100644 --- a/KVA/Migration.Toolkit.Source/Mappers/CmsClassMapper.cs +++ b/KVA/Migration.Toolkit.Source/Mappers/CmsClassMapper.cs @@ -15,6 +15,7 @@ using Migration.Toolkit.Common.MigrationProtocol; using Migration.Toolkit.KXP.Api.Services.CmsClass; using Migration.Toolkit.Source.Contexts; +using Migration.Toolkit.Source.Helpers; using Migration.Toolkit.Source.Model; namespace Migration.Toolkit.Source.Mappers; @@ -53,7 +54,7 @@ protected override DataClassInfo MapInternal(ICmsClass source, DataClassInfo tar logger.LogDebug("{ClassName} is {@Properties}", source.ClassName, new { isCustomizableSystemClass, classIsCustom, source.ClassResourceID, classResource?.ResourceName }); } - MapFormDefinitionFields(source, target, isCustomizableSystemClass, classIsCustom); + FormDefinitionHelper.MapFormDefinitionFields(logger, fieldMigrationService, source, target, isCustomizableSystemClass, classIsCustom); target.ClassHasUnmanagedDbSchema = false; if (!string.IsNullOrWhiteSpace(source.ClassTableName)) @@ -200,39 +201,7 @@ protected override DataClassInfo MapInternal(ICmsClass source, DataClassInfo tar return target; } - private void MapFormDefinitionFields(ICmsClass source, DataClassInfo target, bool isCustomizableSystemClass, bool classIsCustom) - { - if (!string.IsNullOrWhiteSpace(source.ClassFormDefinition)) - { - var patcher = new FormDefinitionPatcher( - logger, - source.ClassFormDefinition, - fieldMigrationService, - source.ClassIsForm.GetValueOrDefault(false), - source.ClassIsDocumentType, - isCustomizableSystemClass, - classIsCustom - ); - - patcher.PatchFields(); - patcher.RemoveCategories(); // TODO tk: 2022-10-11 remove when supported - - string? result = patcher.GetPatched(); - if (isCustomizableSystemClass) - { - result = FormHelper.MergeFormDefinitions(target.ClassFormDefinition, result); - } - - var formInfo = new FormInfo(result); - target.ClassFormDefinition = formInfo.GetXmlDefinition(); - } - else - { - target.ClassFormDefinition = new FormInfo().GetXmlDefinition(); - } - } - - private DataClassInfo PatchDataClassInfo(DataClassInfo dataClass, out string? oldPrimaryKeyName, out string? documentNameField) + public static DataClassInfo PatchDataClassInfo(DataClassInfo dataClass, out string? oldPrimaryKeyName, out string? documentNameField) { oldPrimaryKeyName = null; documentNameField = null; @@ -335,7 +304,7 @@ private static void AppendDocumentNameField(FormInfo nfi, string className, out }); } - private string AppendRequiredValidationRule(string rulesXml) + private static string AppendRequiredValidationRule(string rulesXml) { if (string.IsNullOrWhiteSpace(rulesXml)) { diff --git a/KVA/Migration.Toolkit.Source/Mappers/ContentItemMapper.cs b/KVA/Migration.Toolkit.Source/Mappers/ContentItemMapper.cs index 4012c8fd..a9b68b2c 100644 --- a/KVA/Migration.Toolkit.Source/Mappers/ContentItemMapper.cs +++ b/KVA/Migration.Toolkit.Source/Mappers/ContentItemMapper.cs @@ -3,6 +3,7 @@ using CMS.ContentEngine.Internal; using CMS.Core; using CMS.Core.Internal; +using CMS.DataEngine; using CMS.FormEngine; using CMS.MediaLibrary; using CMS.Websites; @@ -12,6 +13,7 @@ using Microsoft.Extensions.Logging; using Migration.Toolkit.Common; using Migration.Toolkit.Common.Abstractions; +using Migration.Toolkit.Common.Builders; using Migration.Toolkit.Common.Enumerations; using Migration.Toolkit.Common.Helpers; using Migration.Toolkit.Common.Services; @@ -23,6 +25,7 @@ using Migration.Toolkit.Source.Contexts; using Migration.Toolkit.Source.Helpers; using Migration.Toolkit.Source.Model; +using Migration.Toolkit.Source.Providers; using Migration.Toolkit.Source.Services; using Migration.Toolkit.Source.Services.Model; using Newtonsoft.Json; @@ -57,21 +60,28 @@ public class ContentItemMapper( SpoiledGuidContext spoiledGuidContext, EntityIdentityFacade entityIdentityFacade, IAssetFacade assetFacade, + MediaLinkServiceFactory mediaLinkServiceFactory, ToolkitConfiguration configuration, - MediaLinkServiceFactory mediaLinkServiceFactory + ClassMappingProvider classMappingProvider ) : UmtMapperBase { private const string CLASS_FIELD_CONTROL_NAME = "controlname"; protected override IEnumerable MapInternal(CmsTreeMapperSource source) { - (var cmsTree, string safeNodeName, var siteGuid, var nodeParentGuid, var cultureToLanguageGuid, string targetFormDefinition, string sourceFormDefinition, var migratedDocuments, var sourceSite) = source; + (var cmsTree, string safeNodeName, var siteGuid, var nodeParentGuid, var cultureToLanguageGuid, string? targetFormDefinition, string sourceFormDefinition, var migratedDocuments, var sourceSite) = source; logger.LogTrace("Mapping {Value}", new { cmsTree.NodeAliasPath, cmsTree.NodeName, cmsTree.NodeGUID, cmsTree.NodeSiteID }); - var nodeClass = modelFacade.SelectById(cmsTree.NodeClassID) ?? throw new InvalidOperationException($"Fatal: node class is missing, class id '{cmsTree.NodeClassID}'"); + var sourceNodeClass = modelFacade.SelectById(cmsTree.NodeClassID) ?? throw new InvalidOperationException($"Fatal: node class is missing, class id '{cmsTree.NodeClassID}'"); + var mapping = classMappingProvider.GetMapping(sourceNodeClass.ClassName); + var targetClassGuid = sourceNodeClass.ClassGUID; + if (mapping != null) + { + targetClassGuid = DataClassInfoProvider.ProviderObject.Get(mapping.TargetClassName)?.ClassGUID ?? throw new InvalidOperationException($"Unable to find target class '{mapping.TargetClassName}'"); + } - bool migratedAsContentFolder = nodeClass.ClassName.Equals("cms.folder", StringComparison.InvariantCultureIgnoreCase) && !configuration.UseDeprecatedFolderPageType.GetValueOrDefault(false); + bool migratedAsContentFolder = sourceNodeClass.ClassName.Equals("cms.folder", StringComparison.InvariantCultureIgnoreCase) && !configuration.UseDeprecatedFolderPageType.GetValueOrDefault(false); var contentItemGuid = spoiledGuidContext.EnsureNodeGuid(cmsTree.NodeGUID, cmsTree.NodeSiteID, cmsTree.NodeID); yield return new ContentItemModel @@ -80,7 +90,7 @@ protected override IEnumerable MapInternal(CmsTreeMapperSource source ContentItemName = safeNodeName, ContentItemIsReusable = false, // page is not reusable ContentItemIsSecured = cmsTree.IsSecuredNode ?? false, - ContentItemDataClassGuid = migratedAsContentFolder ? null : nodeClass.ClassGUID, + ContentItemDataClassGuid = migratedAsContentFolder ? null : targetClassGuid, ContentItemChannelGuid = siteGuid }; @@ -121,7 +131,7 @@ protected override IEnumerable MapInternal(CmsTreeMapperSource source List? migratedDraft = null; try { - migratedDraft = MigrateDraft(draftVersion, cmsTree, sourceFormDefinition, targetFormDefinition, contentItemGuid, languageGuid, nodeClass, websiteChannelInfo, sourceSite).ToList(); + migratedDraft = MigrateDraft(draftVersion, cmsTree, sourceFormDefinition, targetFormDefinition, contentItemGuid, languageGuid, sourceNodeClass, websiteChannelInfo, sourceSite, mapping).ToList(); draftMigrated = true; } catch @@ -235,17 +245,17 @@ protected override IEnumerable MapInternal(CmsTreeMapperSource source { deferredPathService.AddPatch( commonDataModel.ContentItemCommonDataGUID ?? throw new InvalidOperationException("DocumentGUID is null"), - nodeClass.ClassName, + ContentItemCommonDataInfo.TYPEINFO.ObjectClassName, websiteChannelInfo.WebsiteChannelID ); } if (!migratedAsContentFolder) { - var dataModel = new ContentItemDataModel { ContentItemDataGUID = commonDataModel.ContentItemCommonDataGUID, ContentItemDataCommonDataGuid = commonDataModel.ContentItemCommonDataGUID, ContentItemContentTypeName = nodeClass.ClassName }; + var dataModel = new ContentItemDataModel { ContentItemDataGUID = commonDataModel.ContentItemCommonDataGUID, ContentItemDataCommonDataGuid = commonDataModel.ContentItemCommonDataGUID, ContentItemContentTypeName = mapping?.TargetClassName ?? sourceNodeClass.ClassName }; var fi = new FormInfo(targetFormDefinition); - if (nodeClass.ClassIsCoupledClass) + if (sourceNodeClass.ClassIsCoupledClass) { var sfi = new FormInfo(sourceFormDefinition); string primaryKeyName = ""; @@ -263,23 +273,25 @@ protected override IEnumerable MapInternal(CmsTreeMapperSource source } var commonFields = UnpackReusableFieldSchemas(fi.GetFields()).ToArray(); - var sourceColumns = commonFields - .Select(cf => ReusableSchemaService.RemoveClassPrefix(nodeClass.ClassName, cf.Name)) + var targetColumns = commonFields + .Select(cf => ReusableSchemaService.RemoveClassPrefix(sourceNodeClass.ClassName, cf.Name)) .Union(fi.GetColumnNames(false)) - .Except([CmsClassMapper.GetLegacyDocumentName(fi, nodeClass.ClassName)]) + .Except([CmsClassMapper.GetLegacyDocumentName(fi, sourceNodeClass.ClassName)]) .ToList(); - var coupledDataRow = coupledDataService.GetSourceCoupledDataRow(nodeClass.ClassTableName, primaryKeyName, cmsDocument.DocumentForeignKeyValue); + var coupledDataRow = coupledDataService.GetSourceCoupledDataRow(sourceNodeClass.ClassTableName, primaryKeyName, cmsDocument.DocumentForeignKeyValue); // TODO tomas.krch: 2024-09-05 propagate async to root MapCoupledDataFieldValues(dataModel.CustomProperties, columnName => coupledDataRow?[columnName], columnName => coupledDataRow?.ContainsKey(columnName) ?? false, - cmsTree, cmsDocument.DocumentID, sourceColumns, sfi, fi, false, nodeClass, sourceSite + cmsTree, cmsDocument.DocumentID, + targetColumns, sfi, fi, + false, sourceNodeClass, sourceSite, mapping ).GetAwaiter().GetResult(); foreach (var formFieldInfo in commonFields) { - string originalFieldName = ReusableSchemaService.RemoveClassPrefix(nodeClass.ClassName, formFieldInfo.Name); + string originalFieldName = ReusableSchemaService.RemoveClassPrefix(sourceNodeClass.ClassName, formFieldInfo.Name); if (dataModel.CustomProperties.TryGetValue(originalFieldName, out object? value)) { commonDataModel.CustomProperties ??= []; @@ -294,11 +306,12 @@ protected override IEnumerable MapInternal(CmsTreeMapperSource source } } - if (CmsClassMapper.GetLegacyDocumentName(fi, nodeClass.ClassName) is { } legacyDocumentNameFieldName) + string targetClassName = mapping?.TargetClassName ?? sourceNodeClass.ClassName; + if (CmsClassMapper.GetLegacyDocumentName(fi, targetClassName) is { } legacyDocumentNameFieldName) { - if (reusableSchemaService.IsConversionToReusableFieldSchemaRequested(nodeClass.ClassName)) + if (reusableSchemaService.IsConversionToReusableFieldSchemaRequested(targetClassName)) { - string fieldName = ReusableSchemaService.GetUniqueFieldName(nodeClass.ClassName, legacyDocumentNameFieldName); + string fieldName = ReusableSchemaService.GetUniqueFieldName(targetClassName, legacyDocumentNameFieldName); commonDataModel.CustomProperties.Add(fieldName, cmsDocument.DocumentName); } else @@ -398,7 +411,7 @@ private void PatchJsonDefinitions(int sourceSiteId, ref string? pageTemplateConf } private IEnumerable MigrateDraft(ICmsVersionHistory checkoutVersion, ICmsTree cmsTree, string sourceFormClassDefinition, string targetFormDefinition, Guid contentItemGuid, - Guid contentLanguageGuid, ICmsClass nodeClass, WebsiteChannelInfo websiteChannelInfo, ICmsSite sourceSite) + Guid contentLanguageGuid, ICmsClass sourceNodeClass, WebsiteChannelInfo websiteChannelInfo, ICmsSite sourceSite, IClassMapping mapping) { var adapter = new NodeXmlAdapter(checkoutVersion.NodeXML); @@ -447,14 +460,19 @@ private IEnumerable MigrateDraft(ICmsVersionHistory checkoutVersion, { deferredPathService.AddPatch( commonDataModel.ContentItemCommonDataGUID ?? throw new InvalidOperationException("DocumentGUID is null"), - nodeClass.ClassName, + ContentItemCommonDataInfo.TYPEINFO.ObjectClassName,// sourceNodeClass.ClassName, websiteChannelInfo.WebsiteChannelID ); } - dataModel = new ContentItemDataModel { ContentItemDataGUID = commonDataModel.ContentItemCommonDataGUID, ContentItemDataCommonDataGuid = commonDataModel.ContentItemCommonDataGUID, ContentItemContentTypeName = nodeClass.ClassName }; + dataModel = new ContentItemDataModel + { + ContentItemDataGUID = commonDataModel.ContentItemCommonDataGUID, + ContentItemDataCommonDataGuid = commonDataModel.ContentItemCommonDataGUID, + ContentItemContentTypeName = mapping?.TargetClassName ?? sourceNodeClass.ClassName + }; - if (nodeClass.ClassIsCoupledClass) + if (sourceNodeClass.ClassIsCoupledClass) { var fi = new FormInfo(targetFormDefinition); var sfi = new FormInfo(sourceFormClassDefinition); @@ -474,20 +492,20 @@ private IEnumerable MigrateDraft(ICmsVersionHistory checkoutVersion, var commonFields = UnpackReusableFieldSchemas(fi.GetFields()).ToArray(); var sourceColumns = commonFields - .Select(cf => ReusableSchemaService.RemoveClassPrefix(nodeClass.ClassName, cf.Name)) + .Select(cf => ReusableSchemaService.RemoveClassPrefix(sourceNodeClass.ClassName, cf.Name)) .Union(fi.GetColumnNames(false)) - .Except([CmsClassMapper.GetLegacyDocumentName(fi, nodeClass.ClassName)]) + .Except([CmsClassMapper.GetLegacyDocumentName(fi, sourceNodeClass.ClassName)]) .ToList(); // TODO tomas.krch: 2024-09-05 propagate async to root MapCoupledDataFieldValues(dataModel.CustomProperties, s => adapter.GetValue(s), s => adapter.HasValueSet(s) - , cmsTree, adapter.DocumentID, sourceColumns, sfi, fi, true, nodeClass, sourceSite).GetAwaiter().GetResult(); + , cmsTree, adapter.DocumentID, sourceColumns, sfi, fi, true, sourceNodeClass, sourceSite, mapping).GetAwaiter().GetResult(); foreach (var formFieldInfo in commonFields) { - string originalFieldName = ReusableSchemaService.RemoveClassPrefix(nodeClass.ClassName, formFieldInfo.Name); + string originalFieldName = ReusableSchemaService.RemoveClassPrefix(sourceNodeClass.ClassName, formFieldInfo.Name); if (dataModel.CustomProperties.TryGetValue(originalFieldName, out object? value)) { commonDataModel.CustomProperties ??= []; @@ -503,9 +521,9 @@ private IEnumerable MigrateDraft(ICmsVersionHistory checkoutVersion, } // supply document name - if (reusableSchemaService.IsConversionToReusableFieldSchemaRequested(nodeClass.ClassName)) + if (reusableSchemaService.IsConversionToReusableFieldSchemaRequested(sourceNodeClass.ClassName)) { - string fieldName = ReusableSchemaService.GetUniqueFieldName(nodeClass.ClassName, "DocumentName"); + string fieldName = ReusableSchemaService.GetUniqueFieldName(sourceNodeClass.ClassName, "DocumentName"); commonDataModel.CustomProperties.Add(fieldName, adapter.DocumentName); } else @@ -536,116 +554,166 @@ private async Task MapCoupledDataFieldValues( FormInfo oldFormInfo, FormInfo newFormInfo, bool migratingFromVersionHistory, - ICmsClass nodeClass, - ICmsSite site + ICmsClass sourceNodeClass, + ICmsSite site, + IClassMapping mapping ) { - Debug.Assert(nodeClass.ClassTableName != null, "cmsTree.NodeClass.ClassTableName != null"); + Debug.Assert(sourceNodeClass.ClassTableName != null, "sourceNodeClass.ClassTableName != null"); - foreach (string columnName in newColumnNames) + foreach (string targetColumnName in newColumnNames) { + string targetFieldName = null!; + Func valueConvertor = sourceValue => sourceValue; + switch (mapping?.GetMapping(targetColumnName, sourceNodeClass.ClassName)) + { + case FieldMappingWithConversion fieldMappingWithConversion: + { + targetFieldName = fieldMappingWithConversion.TargetFieldName; + valueConvertor = fieldMappingWithConversion.Converter; + break; + } + case FieldMapping fieldMapping: + { + targetFieldName = fieldMapping.TargetFieldName; + valueConvertor = sourceValue => sourceValue; + break; + } + case null: + { + targetFieldName = targetColumnName; + valueConvertor = sourceValue => sourceValue; + break; + } + + default: + break; + } + if ( - columnName.Equals("ContentItemDataID", StringComparison.InvariantCultureIgnoreCase) || - columnName.Equals("ContentItemDataCommonDataID", StringComparison.InvariantCultureIgnoreCase) || - columnName.Equals("ContentItemDataGUID", StringComparison.InvariantCultureIgnoreCase) || - columnName.Equals(CmsClassMapper.GetLegacyDocumentName(newFormInfo, nodeClass.ClassName), StringComparison.InvariantCultureIgnoreCase) + targetFieldName.Equals("ContentItemDataID", StringComparison.InvariantCultureIgnoreCase) || + targetFieldName.Equals("ContentItemDataCommonDataID", StringComparison.InvariantCultureIgnoreCase) || + targetFieldName.Equals("ContentItemDataGUID", StringComparison.InvariantCultureIgnoreCase) || + targetFieldName.Equals(CmsClassMapper.GetLegacyDocumentName(newFormInfo, sourceNodeClass.ClassName), StringComparison.InvariantCultureIgnoreCase) ) { - logger.LogTrace("Skipping '{FieldName}'", columnName); + logger.LogTrace("Skipping '{FieldName}'", targetFieldName); continue; } #pragma warning disable CS0618 // Type or member is obsolete - if (oldFormInfo.GetFormField(columnName)?.External is true) + if (oldFormInfo.GetFormField(targetFieldName)?.External is true) #pragma warning restore CS0618 // Type or member is obsolete { - logger.LogTrace("Skipping '{FieldName}' - is external", columnName); + logger.LogTrace("Skipping '{FieldName}' - is external", targetFieldName); continue; } - if (!containsSourceValue(columnName)) + string sourceFieldName = mapping?.GetSourceFieldName(targetColumnName, sourceNodeClass.ClassName) ?? targetColumnName; + if (!containsSourceValue(sourceFieldName)) { if (migratingFromVersionHistory) { - logger.LogDebug("Value is not contained in source, field '{Field}' (possibly because version existed before field was added to class form)", columnName); + logger.LogDebug("Value is not contained in source, field '{Field}' (possibly because version existed before field was added to class form)", targetColumnName); } else { - logger.LogWarning("Value is not contained in source, field '{Field}'", columnName); + logger.LogWarning("Value is not contained in source, field '{Field}'", targetColumnName); } continue; } - var field = oldFormInfo.GetFormField(columnName); + var field = oldFormInfo.GetFormField(sourceFieldName); string? controlName = field.Settings[CLASS_FIELD_CONTROL_NAME]?.ToString()?.ToLowerInvariant(); - object? value = getSourceValue(columnName); - target[columnName] = value; - - var fieldMigration = fieldMigrationService.GetFieldMigration(field.DataType, controlName, columnName); - if (fieldMigration?.Actions?.Contains(TcaDirective.ConvertToAsset) ?? false) + object? sourceValue = getSourceValue(sourceFieldName); + target[targetFieldName] = valueConvertor.Invoke(sourceValue); + var fvmc = new FieldMigrationContext(field.DataType, controlName, targetColumnName, new DocumentSourceObjectContext(cmsTree, sourceNodeClass, site, oldFormInfo, newFormInfo, documentId)); + var fmb = fieldMigrationService.GetFieldMigration(fvmc); + if (fmb is FieldMigration fieldMigration) { - await ConvertToAsset(target, cmsTree, documentId, value, columnName, controlName, field, fieldMigration, site); - continue; - } - - if (controlName != null) - { - if (fieldMigration.Actions?.Contains(TcaDirective.ConvertToPages) ?? false) + if (controlName != null) { - // relation to other document - var convertedRelation = relationshipService.GetNodeRelationships(cmsTree.NodeID, nodeClass.ClassName, field.Guid) - .Select(r => new WebPageRelatedItem { WebPageGuid = spoiledGuidContext.EnsureNodeGuid(r.RightNode.NodeGUID, r.RightNode.NodeSiteID, r.RightNode.NodeID) }); + if (fieldMigration.Actions?.Contains(TcaDirective.ConvertToPages) ?? false) + { + // relation to other document + var convertedRelation = relationshipService.GetNodeRelationships(cmsTree.NodeID, sourceNodeClass.ClassName, field.Guid) + .Select(r => new WebPageRelatedItem { WebPageGuid = spoiledGuidContext.EnsureNodeGuid(r.RightNode.NodeGUID, r.RightNode.NodeSiteID, r.RightNode.NodeID) }); - target.SetValueAsJson(columnName, convertedRelation); - } - else - { - // leave as is - target[columnName] = value; - } + target.SetValueAsJson(targetFieldName, valueConvertor.Invoke(convertedRelation)); + } + else + { + // leave as is + target[targetFieldName] = valueConvertor.Invoke(sourceValue); + } - if (fieldMigration.TargetFormComponent == "webpages") - { - if (value is string pageReferenceJson) + if (fieldMigration.TargetFormComponent == "webpages") { - var parsed = JObject.Parse(pageReferenceJson); - foreach (var jToken in parsed.DescendantsAndSelf()) + if (sourceValue is string pageReferenceJson) { - if (jToken.Path.EndsWith("NodeGUID", StringComparison.InvariantCultureIgnoreCase)) + var parsed = JObject.Parse(pageReferenceJson); + foreach (var jToken in parsed.DescendantsAndSelf()) { - var patchedGuid = spoiledGuidContext.EnsureNodeGuid(jToken.Value(), cmsTree.NodeSiteID); - jToken.Replace(JToken.FromObject(patchedGuid)); + if (jToken.Path.EndsWith("NodeGUID", StringComparison.InvariantCultureIgnoreCase)) + { + var patchedGuid = spoiledGuidContext.EnsureNodeGuid(jToken.Value(), cmsTree.NodeSiteID); + jToken.Replace(JToken.FromObject(patchedGuid)); + } } - } - target[columnName] = parsed.ToString().Replace("\"NodeGuid\"", "\"WebPageGuid\""); + target[targetFieldName] = valueConvertor.Invoke(parsed.ToString().Replace("\"NodeGuid\"", "\"WebPageGuid\"")); + } } } + else + { + target[targetFieldName] = valueConvertor.Invoke(sourceValue); + } + } + else if (fmb != null) + { + switch (await fmb.MigrateValue(sourceValue, fvmc)) + { + case { Success: true } result: + { + target[targetFieldName] = valueConvertor.Invoke(result.MigratedValue); + break; + } + case { Success: false }: + { + logger.LogError("Error while migrating field '{Field}' value {Value}", targetFieldName, sourceValue); + break; + } + + default: + break; + } } else { - target[columnName] = value; + target[targetFieldName] = valueConvertor?.Invoke(sourceValue); } - var newField = newFormInfo.GetFormField(columnName); + var newField = newFormInfo.GetFormField(targetColumnName); if (newField == null) { + var commonFields = UnpackReusableFieldSchemas(newFormInfo.GetFields()).ToArray(); newField = commonFields - .FirstOrDefault(cf => ReusableSchemaService.RemoveClassPrefix(nodeClass.ClassName, cf.Name).Equals(columnName, StringComparison.InvariantCultureIgnoreCase)); + .FirstOrDefault(cf => ReusableSchemaService.RemoveClassPrefix(mapping?.TargetClassName ?? sourceNodeClass.ClassName, cf.Name).Equals(targetColumnName, StringComparison.InvariantCultureIgnoreCase)); } string? newControlName = newField?.Settings[CLASS_FIELD_CONTROL_NAME]?.ToString()?.ToLowerInvariant(); - if (newControlName?.Equals(FormComponents.AdminRichTextEditorComponent, StringComparison.InvariantCultureIgnoreCase) == true && target[columnName] is string { } html && !string.IsNullOrWhiteSpace(html) && + if (newControlName?.Equals(FormComponents.AdminRichTextEditorComponent, StringComparison.InvariantCultureIgnoreCase) == true && target[targetColumnName] is string { } html && !string.IsNullOrWhiteSpace(html) && !configuration.MigrateMediaToMediaLibrary) { var mediaLinkService = mediaLinkServiceFactory.Create(); var htmlProcessor = new HtmlProcessor(html, mediaLinkService); - target[columnName] = await htmlProcessor.ProcessHtml(site.SiteID, async (result, original) => + target[targetColumnName] = await htmlProcessor.ProcessHtml(site.SiteID, async (result, original) => { switch (result) { @@ -688,243 +756,6 @@ ICmsSite site } } - private async Task ConvertToAsset(Dictionary target, ICmsTree cmsTree, int? documentId, object? value, string columnName, string? controlName, FormFieldInfo field, FieldMigration fieldMigration, ICmsSite site) - { - List mfis = []; - bool hasMigratedAsset = false; - if (value is string link && - mediaLinkServiceFactory.Create().MatchMediaLink(link, site.SiteID) is (true, var mediaLinkKind, var mediaKind, var path, var mediaGuid, var linkSiteId, var libraryDir) result) - { - if (mediaLinkKind == MediaLinkKind.Path) - { - // path needs to be converted to GUID - if (mediaKind == MediaKind.Attachment && path != null) - { - switch (await attachmentMigrator.TryMigrateAttachmentByPath(path, $"__{columnName}")) - { - case MigrateAttachmentResultMediaFile(true, _, var x, _): - { - mfis = [new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize }]; - hasMigratedAsset = true; - logger.LogTrace("'{FieldName}' migrated Match={Value}", columnName, result); - break; - } - case MigrateAttachmentResultContentItem { Success: true, ContentItemGuid: { } contentItemGuid }: - { - mfis = - [ - new ContentItemReference { Identifier = contentItemGuid } - ]; - hasMigratedAsset = true; - logger.LogTrace("'{FieldName}' migrated Match={Value}", columnName, result); - break; - } - default: - { - logger.LogTrace("Unsuccessful attachment migration '{Field}': '{Value}' - {Match}", columnName, path, result); - break; - } - } - } - - if (mediaKind == MediaKind.MediaFile) - { - logger.LogTrace("'{FieldName}' Skipped Match={Value}", columnName, result); - } - } - - if (mediaGuid is { } mg) - { - if (mediaKind == MediaKind.Attachment) - { - switch (await attachmentMigrator.MigrateAttachment(mg, $"__{columnName}", cmsTree.NodeSiteID)) - { - case MigrateAttachmentResultMediaFile(true, _, var x, _): - { - mfis = [new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize }]; - hasMigratedAsset = true; - logger.LogTrace("MediaFile migrated from attachment '{Field}': '{Value}'", columnName, mg); - break; - } - case MigrateAttachmentResultContentItem { Success: true, ContentItemGuid: { } contentItemGuid }: - { - mfis = - [ - new ContentItemReference { Identifier = contentItemGuid } - ]; - hasMigratedAsset = true; - logger.LogTrace("Content item migrated from attachment '{Field}': '{Value}' to {ContentItemGUID}", columnName, mg, contentItemGuid); - break; - } - default: - { - break; - } - } - } - - if (mediaKind == MediaKind.MediaFile) - { - var sourceMediaFile = modelFacade.SelectWhere("FileGUID = @mediaFileGuid AND FileSiteID = @fileSiteID", new SqlParameter("mediaFileGuid", mg), new SqlParameter("fileSiteID", site.SiteID)) - .FirstOrDefault(); - if (sourceMediaFile != null) - { - if (configuration.MigrateMediaToMediaLibrary) - { - if (entityIdentityFacade.Translate(sourceMediaFile) is { } mf && mediaFileFacade.GetMediaFile(mf.Identity) is { } x) - { - mfis = [new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize }]; - hasMigratedAsset = true; - } - } - else - { - var (ownerContentItemGuid, _) = assetFacade.GetRef(sourceMediaFile); - mfis = - [ - new ContentItemReference { Identifier = ownerContentItemGuid } - ]; - hasMigratedAsset = true; - logger.LogTrace("MediaFile migrated from media file '{Field}': '{Value}'", columnName, mg); - } - } - } - } - } - else if (classService.GetFormControlDefinition(controlName) is { } formControl) - { - switch (formControl) - { - case { UserControlForFile: true }: - { - if (value is Guid attachmentGuid) - { - switch (await attachmentMigrator.MigrateAttachment(attachmentGuid, $"__{columnName}", cmsTree.NodeSiteID)) - { - case MigrateAttachmentResultMediaFile(true, _, var mfi, _): - { - mfis = [new AssetRelatedItem { Identifier = mfi.FileGUID, Dimensions = new AssetDimensions { Height = mfi.FileImageHeight, Width = mfi.FileImageWidth }, Name = mfi.FileName, Size = mfi.FileSize }]; - hasMigratedAsset = true; - logger.LogTrace("MediaFile migrated from attachment '{Field}': '{Value}'", columnName, attachmentGuid); - break; - } - case MigrateAttachmentResultContentItem { Success: true, ContentItemGuid: { } contentItemGuid }: - { - mfis = - [ - new ContentItemReference { Identifier = contentItemGuid } - ]; - hasMigratedAsset = true; - logger.LogTrace("Content item migrated from attachment '{Field}': '{Value}' to {ContentItemGUID}", columnName, attachmentGuid, contentItemGuid); - break; - } - default: - { - logger.LogTrace("'{FieldName}' UserControlForFile Success={Success} AttachmentGUID={attachmentGuid}", columnName, false, attachmentGuid); - break; - } - } - } - else if (value is string attachmentGuidStr && Guid.TryParse(attachmentGuidStr, out attachmentGuid)) - { - switch (await attachmentMigrator.MigrateAttachment(attachmentGuid, $"__{columnName}", cmsTree.NodeSiteID)) - { - case MigrateAttachmentResultMediaFile { Success: true, MediaFileInfo: { } x }: - { - mfis = [new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize }]; - hasMigratedAsset = true; - logger.LogTrace("MediaFile migrated from attachment '{Field}': '{Value}' (parsed)", columnName, attachmentGuid); - break; - } - case MigrateAttachmentResultContentItem { Success: true, ContentItemGuid: { } contentItemGuid }: - { - mfis = - [ - new ContentItemReference { Identifier = contentItemGuid } - ]; - hasMigratedAsset = true; - logger.LogTrace("Content item migrated from attachment '{Field}': '{Value}' to {ContentItemGUID}", columnName, attachmentGuid, contentItemGuid); - break; - } - default: - { - logger.LogTrace("'{FieldName}' UserControlForFile Success={Success} AttachmentGUID={attachmentGuid}", columnName, false, attachmentGuid); - break; - } - } - } - else - { - logger.LogTrace("'{FieldName}' UserControlForFile AttachmentGUID={Value}", columnName, value); - } - - break; - } - case { UserControlForDocAttachments: true }: - { - // new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize } - if (documentId is { } docId) - { - var mfisl = new List(); - await foreach (var migResult in attachmentMigrator.MigrateGroupedAttachments(docId, field.Guid, field.Name)) - { - switch (migResult) - { - case MigrateAttachmentResultMediaFile { Success: true, MediaFileInfo: { } x }: - { - mfisl.Add(new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize }); - hasMigratedAsset = true; - break; - } - case MigrateAttachmentResultContentItem { Success: true, ContentItemGuid: { } contentItemGuid }: - { - mfis = - [ - new ContentItemReference { Identifier = contentItemGuid } - ]; - hasMigratedAsset = true; - logger.LogTrace("Content item migrated from document '{DocumentID}' attachment '{FiledName}' to {ContentItemGUID}", docId, field.Name, contentItemGuid); - break; - } - default: - { - hasMigratedAsset = false; - break; - } - } - } - - mfis = mfisl; - } - else - { - logger.LogTrace("'{FieldName}' UserControlForDocAttachments DocumentID={Value}", columnName, documentId); - } - - break; - } - - default: - break; - } - } - else - { - logger.LogWarning("Unable to map value based on selected migration '{Migration}', value: '{Value}'", fieldMigration, value); - return; - } - - if (hasMigratedAsset && mfis is { Count: > 0 }) - { - target.SetValueAsJson(columnName, mfis); - logger.LogTrace("'{FieldName}' setting '{Value}'", columnName, target.GetValueOrDefault(columnName)); - } - else - { - logger.LogTrace("'{FieldName}' leaving '{Value}'", columnName, target.GetValueOrDefault(columnName)); - } - } - private static IEnumerable UnpackReusableFieldSchemas(IEnumerable schemaInfos) { using var siEnum = schemaInfos.GetEnumerator(); diff --git a/KVA/Migration.Toolkit.Source/Providers/ClassMappingProvider.cs b/KVA/Migration.Toolkit.Source/Providers/ClassMappingProvider.cs new file mode 100644 index 00000000..758e770b --- /dev/null +++ b/KVA/Migration.Toolkit.Source/Providers/ClassMappingProvider.cs @@ -0,0 +1,22 @@ +using Migration.Toolkit.Common.Builders; + +namespace Migration.Toolkit.Source.Providers; + +public class ClassMappingProvider(IEnumerable classMappings) +{ + private readonly Dictionary mappingsByClassName = classMappings.Aggregate(new Dictionary(StringComparer.InvariantCultureIgnoreCase), + (current, sourceClassMapping) => + { + foreach (string s2Cl in sourceClassMapping.SourceClassNames) + { + if (!current.TryAdd(s2Cl, sourceClassMapping)) + { + throw new InvalidOperationException($"Incorrectly defined class mapping - duplicate found for class '{s2Cl}'. Fix mapping before proceeding with migration."); + } + } + + return current; + }); + + public IClassMapping? GetMapping(string className) => mappingsByClassName.GetValueOrDefault(className); +} diff --git a/KVA/Migration.Toolkit.Source/Services/ReusableSchemaService.cs b/KVA/Migration.Toolkit.Source/Services/ReusableSchemaService.cs index 384d5f92..e6c3d527 100644 --- a/KVA/Migration.Toolkit.Source/Services/ReusableSchemaService.cs +++ b/KVA/Migration.Toolkit.Source/Services/ReusableSchemaService.cs @@ -84,9 +84,29 @@ public void AddReusableSchemaToDataClass(DataClassInfo dataClassInfo, Guid reusa dataClassInfo.ClassFormDefinition = formInfo.GetXmlDefinition(); } - public Guid EnsureReusableFieldSchema(string name, string displayName, string description, params FormFieldInfo[] fields) + public void AddReusableSchemaToDataClass(DataClassInfo dataClassInfo, string reusableFieldSchemaName) { - var reusableSchemaGuid = GuidHelper.CreateReusableSchemaGuid($"{name}|{displayName}"); + var formInfo = new FormInfo(dataClassInfo.ClassFormDefinition); + var schema = reusableFieldSchemaManager.Get(reusableFieldSchemaName); + formInfo.AddFormItem(new FormSchemaInfo { Name = dataClassInfo.ClassName, Guid = schema.Guid }); + dataClassInfo.ClassFormDefinition = formInfo.GetXmlDefinition(); + } + + public IEnumerable GetFieldsFromReusableSchema(DataClassInfo dataClassInfo) + { + var formInfo = new FormInfo(dataClassInfo.ClassFormDefinition); + foreach (var formSchemaInfo in formInfo.GetFields()) + { + foreach (var formFieldInfo in reusableFieldSchemaManager.GetSchemaFields(formSchemaInfo.Name)) + { + yield return formFieldInfo; + } + } + } + + public Guid EnsureReusableFieldSchema(string name, string displayName, string? description, params FormFieldInfo[] fields) + { + var reusableSchemaGuid = GuidHelper.CreateReusableSchemaGuid($"{name}"); var schema = reusableFieldSchemaManager.Get(reusableSchemaGuid); if (schema == null) { diff --git a/Migration.Tool.Extensions/ClassMappings/ClassMappingSample.cs b/Migration.Tool.Extensions/ClassMappings/ClassMappingSample.cs new file mode 100644 index 00000000..34d85443 --- /dev/null +++ b/Migration.Tool.Extensions/ClassMappings/ClassMappingSample.cs @@ -0,0 +1,205 @@ +using CMS.DataEngine; +using CMS.FormEngine; +using Microsoft.Extensions.DependencyInjection; +using Migration.Toolkit.Common.Builders; +using Migration.Toolkit.KXP.Api.Auxiliary; + +// ReSharper disable ArrangeMethodOrOperatorBody + +namespace Migration.Tool.Extensions.ClassMappings; + +public static class ClassMappingSample +{ + public static IServiceCollection AddSimpleRemodelingSample(this IServiceCollection serviceCollection) + { + const string targetClassName = "DancingGoatCore.CoffeeRemodeled"; + // declare target class + var m = new MultiClassMapping(targetClassName, target => + { + target.ClassName = targetClassName; + target.ClassTableName = "DancingGoatCore_CoffeeRemodeled"; + target.ClassDisplayName = "Coffee remodeled"; + target.ClassType = ClassType.CONTENT_TYPE; + target.ClassContentTypeType = ClassContentTypeType.WEBSITE; + }); + + // set new primary key + m.BuildField("CoffeeRemodeledID").AsPrimaryKey(); + + // change fields according to new requirements + const string sourceClassName = "DancingGoatCore.Coffee"; + m + .BuildField("FarmRM") + .SetFrom(sourceClassName, "CoffeeFarm", true) + .WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Farm RM")); + + m + .BuildField("CoffeeCountryRM") + .WithFieldPatch(f => f.Caption = "Country RM") + .SetFrom(sourceClassName, "CoffeeCountry", true); + + m + .BuildField("CoffeeVarietyRM") + .SetFrom(sourceClassName, "CoffeeVariety", true) + .WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Variety RM")); + + m + .BuildField("CoffeeProcessingRM") + .SetFrom(sourceClassName, "CoffeeProcessing", true) + .WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Processing RM")); + + m + .BuildField("CoffeeAltitudeRM") + .SetFrom(sourceClassName, "CoffeeAltitude", true) + .WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Altitude RM")); + + m + .BuildField("CoffeeIsDecafRM") + .SetFrom(sourceClassName, "CoffeeIsDecaf", true) + .WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "IsDecaf RM")); + + // register class mapping + serviceCollection.AddSingleton(m); + + return serviceCollection; + } + + public static IServiceCollection AddClassMergeExample(this IServiceCollection serviceCollection) + { + const string targetClassName = "ET.Event"; + const string sourceClassName1 = "_ET.Event1"; + const string sourceClassName2 = "_ET.Event2"; + + var m = new MultiClassMapping(targetClassName, target => + { + target.ClassName = targetClassName; + target.ClassTableName = "ET_Event"; + target.ClassDisplayName = "ET - MY new transformed event"; + target.ClassType = ClassType.CONTENT_TYPE; + target.ClassContentTypeType = ClassContentTypeType.WEBSITE; + }); + + m.BuildField("EventID").AsPrimaryKey(); + + // build new field + var title = m.BuildField("Title"); + // map "EventTitle" field form source data class "_ET.Event1" also use it as template for target field + title.SetFrom(sourceClassName1, "EventTitle", true); + // map "EventTitle" field form source data class "_ET.Event2" + title.SetFrom(sourceClassName2, "EventTitle"); + // patch field definition, in this case lets change field caption + title.WithFieldPatch(f => f.Caption = "Event title"); + + var description = m.BuildField("Description"); + description.SetFrom(sourceClassName2, "EventSmallDesc", true); + description.WithFieldPatch(f => f.Caption = "Event description"); + + var teaser = m.BuildField("Teaser"); + teaser.SetFrom(sourceClassName1, "EventTeaser", true); + teaser.WithFieldPatch(f => f.Caption = "Event teaser"); + + var text = m.BuildField("Text"); + text.SetFrom(sourceClassName1, "EventText", true); + text.SetFrom(sourceClassName2, "EventHtml"); + text.WithFieldPatch(f => f.Caption = "Event text"); + + var startDate = m.BuildField("StartDate"); + startDate.SetFrom(sourceClassName1, "EventDateStart", true); + // if needed use value conversion to adapt value + startDate.ConvertFrom(sourceClassName2, "EventStartDateAsText", false, + v => v?.ToString() is { } av && !string.IsNullOrWhiteSpace(av) ? DateTime.Parse(av) : null + ); + startDate.WithFieldPatch(f => f.Caption = "Event start date"); + + serviceCollection.AddSingleton(m); + + return serviceCollection; + } + + public static IServiceCollection AddReusableSchemaIntegrationSample(this IServiceCollection serviceCollection) + { + const string schemaNameDgcCommon = "DGC.Address"; + const string sourceClassName = "DancingGoatCore.Cafe"; + + // create instance of reusable schema builder - class will help us with definition of new reusable schema + var sb = new ReusableSchemaBuilder(schemaNameDgcCommon, "Common address", "Reusable schema that defines common address"); + + sb + .BuildField("City") + .WithFactory(() => new FormFieldInfo + { + Name = "City", + Caption = "City", + Guid = new Guid("F9DC7EBE-29CA-4591-BF43-E782D50624AF"), + DataType = FieldDataType.Text, + Size = 400, + Settings = + { + ["controlname"] = FormComponents.AdminTextInputComponent + } + }); + + sb + .BuildField("Street") + .WithFactory(() => new FormFieldInfo + { + Name = "Street", + Caption = "Street", + Guid = new Guid("712C0B07-45AC-4CD0-A355-2BA4C46941B6"), + DataType = FieldDataType.Text, + Size = 400, + Settings = + { + ["controlname"] = FormComponents.AdminTextInputComponent + } + }); + + sb + .BuildField("ZipCode") + .CreateFrom(sourceClassName, "CafeZipCode"); + + sb + .BuildField("Phone") + .CreateFrom(sourceClassName, "CafePhone"); + + + var m = new MultiClassMapping("DancingGoatCore.CafeRS", target => + { + target.ClassName = "DancingGoatCore.CafeRS"; + target.ClassTableName = "DancingGoatCore_CafeRS"; + target.ClassDisplayName = "Coffee with reusable schema"; + target.ClassType = ClassType.CONTENT_TYPE; + target.ClassContentTypeType = ClassContentTypeType.WEBSITE; + }); + + // set primary key + m.BuildField("CafeID").AsPrimaryKey(); + + // declare that we intend to use reusable schema and set mappings to new fields from old ones + m.UseResusableSchema(schemaNameDgcCommon); + m.BuildField("City").SetFrom(sourceClassName, "CafeCity"); + m.BuildField("Street").SetFrom(sourceClassName, "CafeStreet"); + m.BuildField("ZipCode").SetFrom(sourceClassName, "CafeZipCode"); + m.BuildField("Phone").SetFrom(sourceClassName, "CafePhone"); + + // old fields we leave in data class + m.BuildField("CafeName").SetFrom(sourceClassName, "CafeName", isTemplate: true); + m.BuildField("CafePhoto").SetFrom(sourceClassName, "CafePhoto", isTemplate: true); + m.BuildField("CafeAdditionalNotes").SetFrom(sourceClassName, "CafeAdditionalNotes", isTemplate: true); + + // in similar manner we can define other classes where we want to use reusable schema + // var m2 = new MultiClassMapping("DancingGoatCore.MyOtherClass", target => + // ... + // m2.UseResusableSchema(schemaNameDgcCommon); + // m2.BuildField ... + // serviceCollection.AddSingleton(m2); + + // register mapping + serviceCollection.AddSingleton(m); + // register reusable schema builder + serviceCollection.AddSingleton(sb); + + return serviceCollection; + } +} + diff --git a/Migration.Tool.Extensions/CommunityMigrations/SampleTextMigration.cs b/Migration.Tool.Extensions/CommunityMigrations/SampleTextMigration.cs new file mode 100644 index 00000000..4160a21c --- /dev/null +++ b/Migration.Tool.Extensions/CommunityMigrations/SampleTextMigration.cs @@ -0,0 +1,65 @@ +using System.Xml.Linq; +using CMS.DataEngine; +using Microsoft.Extensions.Logging; +using Migration.Toolkit.Common; +using Migration.Toolkit.KXP.Api.Auxiliary; +using Migration.Toolkit.KXP.Api.Services.CmsClass; +using Migration.Toolkit.Source.Contexts; +using Migration.Toolkit.Source.Services; + +namespace Migration.Tool.Extensions.CommunityMigrations; + +public class SampleTextMigration(ILogger logger, SpoiledGuidContext spoiledGuidContext) : IFieldMigration +{ + // Migrations will be sorted by this number before checking with "ShallMigrate" method. Set rank to number bellow 100 000 (default migration will have 100 000 or higher) + public int Rank => 5000; + + // this method will check, if this implementation handles migration (for both, definition and value) + public bool ShallMigrate(FieldMigrationContext context) => context.SourceDataType is "text" or "longtext" && context.SourceFormControl is "MY_COMMUNITY_TEXT_EDITOR"; + + public void MigrateFieldDefinition(FormDefinitionPatcher formDefinitionPatcher, XElement field, XAttribute? columnTypeAttr, string fieldDescriptor) + { + // now we migrate field definition + + // field is element from class form definition, for example + /* + + + Media selection + + + MediaSelectionControl + True + + + */ + + // we usually want to change field type and column type, lets assume our custom control content is HTML, then target type would be: + columnTypeAttr?.SetValue(FieldDataType.RichTextHTML); + + var settings = field.EnsureElement(FormDefinitionPatcher.FieldElemSettings); + // RichText editor control + settings.EnsureElement(FormDefinitionPatcher.SettingsElemControlname, e => e.Value = FormComponents.AdminRichTextEditorComponent); + // and rich text configuration + settings.EnsureElement("ConfigurationName", e => e.Value = "Kentico.Administration.StructuredContent"); + } + + public async Task MigrateValue(object? sourceValue, FieldMigrationContext context) + { + // if required, migrate value (for example cut out any unsupported features or migrated them to kentico supported variants if available) + + // check context + if (context.SourceObjectContext is DocumentSourceObjectContext(_, _, _, _, _, _)) + { + // do migration logic + + // return result + return new FieldMigrationResult(true, sourceValue); + } + else + { + return new FieldMigrationResult(false, null); + } + } +} diff --git a/Migration.Tool.Extensions/DefaultMigrations/AssetMigration.cs b/Migration.Tool.Extensions/DefaultMigrations/AssetMigration.cs new file mode 100644 index 00000000..4bad3e49 --- /dev/null +++ b/Migration.Tool.Extensions/DefaultMigrations/AssetMigration.cs @@ -0,0 +1,336 @@ +using System.Xml.Linq; +using CMS.ContentEngine; +using CMS.DataEngine; +using CMS.MediaLibrary; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using Migration.Toolkit.Common; +using Migration.Toolkit.Common.Enumerations; +using Migration.Toolkit.Common.Helpers; +using Migration.Toolkit.KXP.Api; +using Migration.Toolkit.KXP.Api.Auxiliary; +using Migration.Toolkit.KXP.Api.Services.CmsClass; +using Migration.Toolkit.Source; +using Migration.Toolkit.Source.Auxiliary; +using Migration.Toolkit.Source.Contexts; +using Migration.Toolkit.Source.Helpers; +using Migration.Toolkit.Source.Model; +using Migration.Toolkit.Source.Services; + +namespace Migration.Tool.Extensions.DefaultMigrations; + +public class AssetMigration( + ILogger logger, + ClassService classService, + IAttachmentMigrator attachmentMigrator, + ModelFacade modelFacade, + KxpMediaFileFacade mediaFileFacade, + ToolkitConfiguration configuration, + EntityIdentityFacade entityIdentityFacade, + IAssetFacade assetFacade, + MediaLinkServiceFactory mediaLinkServiceFactory +) : IFieldMigration +{ + public int Rank => 100_000; + + public bool ShallMigrate(FieldMigrationContext context) => + ( + context.SourceDataType is KsFieldDataType.DocAttachments or KsFieldDataType.File || + Kx13FormControls.UserControlForText.MediaSelectionControl.Equals(context.SourceFormControl, StringComparison.InvariantCultureIgnoreCase) + ) && + context.SourceObjectContext + // this migration can handle only migration of documents to content items + is DocumentSourceObjectContext + // this migration also handles empty object context - for example when migrating data class, empty context is supplied + or EmptySourceObjectContext; + + public async Task MigrateValue(object? sourceValue, FieldMigrationContext context) + { + (string? _, string? sourceFormControl, string? fieldName, var sourceObjectContext) = context; + if (sourceObjectContext is not DocumentSourceObjectContext(_, _, var cmsSite, var oldFormInfo, _, var documentId)) + { + throw new ArgumentNullException(nameof(sourceObjectContext)); + } + + var field = oldFormInfo.GetFormField(fieldName); + + List mfis = []; + bool hasMigratedAsset = false; + if (sourceValue is string link && + mediaLinkServiceFactory.Create().MatchMediaLink(link, cmsSite.SiteID) is (true, var mediaLinkKind, var mediaKind, var path, var mediaGuid, _, _) result) + { + if (mediaLinkKind == MediaLinkKind.Path) + { + // path needs to be converted to GUID + if (mediaKind == MediaKind.Attachment && path != null) + { + switch (await attachmentMigrator.TryMigrateAttachmentByPath(path, $"__{fieldName}")) + { + case MigrateAttachmentResultMediaFile(true, _, var x, _): + { + mfis = [new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize }]; + hasMigratedAsset = true; + logger.LogTrace("'{FieldName}' migrated Match={Value}", fieldName, result); + break; + } + case MigrateAttachmentResultContentItem { Success: true, ContentItemGuid: { } contentItemGuid }: + { + mfis = + [ + new ContentItemReference { Identifier = contentItemGuid } + ]; + hasMigratedAsset = true; + logger.LogTrace("'{FieldName}' migrated Match={Value}", fieldName, result); + break; + } + default: + { + logger.LogTrace("Unsuccessful attachment migration '{Field}': '{Value}' - {Match}", fieldName, path, result); + break; + } + } + } + + if (mediaKind == MediaKind.MediaFile) + { + logger.LogTrace("'{FieldName}' Skipped Match={Value}", fieldName, result); + } + } + + if (mediaLinkKind == MediaLinkKind.DirectMediaPath) + { + if (mediaKind == MediaKind.MediaFile) + { + var sourceMediaFile = MediaHelper.GetMediaFile(result, modelFacade); + if (sourceMediaFile != null) + { + if (configuration.MigrateMediaToMediaLibrary) + { + if (entityIdentityFacade.Translate(sourceMediaFile) is { } mf && mediaFileFacade.GetMediaFile(mf.Identity) is { } x) + { + mfis = [new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize }]; + hasMigratedAsset = true; + } + } + else + { + var (ownerContentItemGuid, _) = assetFacade.GetRef(sourceMediaFile); + mfis = + [ + new ContentItemReference { Identifier = ownerContentItemGuid } + ]; + hasMigratedAsset = true; + logger.LogTrace("MediaFile migrated from media file '{Field}': '{Value}'", fieldName, result); + } + } + } + } + + if (mediaGuid is { } mg) + { + if (mediaKind == MediaKind.Attachment) + { + switch (await attachmentMigrator.MigrateAttachment(mg, $"__{fieldName}", cmsSite.SiteID)) + { + case MigrateAttachmentResultMediaFile(true, _, var x, _): + { + mfis = [new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize }]; + hasMigratedAsset = true; + logger.LogTrace("MediaFile migrated from attachment '{Field}': '{Value}'", fieldName, mg); + break; + } + case MigrateAttachmentResultContentItem { Success: true, ContentItemGuid: { } contentItemGuid }: + { + mfis = + [ + new ContentItemReference { Identifier = contentItemGuid } + ]; + hasMigratedAsset = true; + logger.LogTrace("Content item migrated from attachment '{Field}': '{Value}' to {ContentItemGUID}", fieldName, mg, contentItemGuid); + break; + } + default: + { + break; + } + } + } + + if (mediaKind == MediaKind.MediaFile) + { + var sourceMediaFile = modelFacade.SelectWhere("FileGUID = @mediaFileGuid AND FileSiteID = @fileSiteID", new SqlParameter("mediaFileGuid", mg), new SqlParameter("fileSiteID", cmsSite.SiteID)) + .FirstOrDefault(); + if (sourceMediaFile != null) + { + if (configuration.MigrateMediaToMediaLibrary) + { + if (entityIdentityFacade.Translate(sourceMediaFile) is { } mf && mediaFileFacade.GetMediaFile(mf.Identity) is { } x) + { + mfis = [new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize }]; + hasMigratedAsset = true; + } + } + else + { + var (ownerContentItemGuid, _) = assetFacade.GetRef(sourceMediaFile); + mfis = + [ + new ContentItemReference { Identifier = ownerContentItemGuid } + ]; + hasMigratedAsset = true; + logger.LogTrace("MediaFile migrated from media file '{Field}': '{Value}'", fieldName, mg); + } + } + } + } + } + else if (classService.GetFormControlDefinition(sourceFormControl) is { } formControl) + { + switch (formControl) + { + case { UserControlForFile: true }: + { + if (sourceValue is Guid attachmentGuid) + { + switch (await attachmentMigrator.MigrateAttachment(attachmentGuid, $"__{fieldName}", cmsSite.SiteID)) + { + case MigrateAttachmentResultMediaFile(true, _, var mfi, _): + { + mfis = [new AssetRelatedItem { Identifier = mfi.FileGUID, Dimensions = new AssetDimensions { Height = mfi.FileImageHeight, Width = mfi.FileImageWidth }, Name = mfi.FileName, Size = mfi.FileSize }]; + hasMigratedAsset = true; + logger.LogTrace("MediaFile migrated from attachment '{Field}': '{Value}'", fieldName, attachmentGuid); + break; + } + case MigrateAttachmentResultContentItem { Success: true, ContentItemGuid: { } contentItemGuid }: + { + mfis = + [ + new ContentItemReference { Identifier = contentItemGuid } + ]; + hasMigratedAsset = true; + logger.LogTrace("Content item migrated from attachment '{Field}': '{Value}' to {ContentItemGUID}", fieldName, attachmentGuid, contentItemGuid); + break; + } + default: + { + logger.LogTrace("'{FieldName}' UserControlForFile Success={Success} AttachmentGUID={attachmentGuid}", fieldName, false, attachmentGuid); + break; + } + } + } + else if (sourceValue is string attachmentGuidStr && Guid.TryParse(attachmentGuidStr, out attachmentGuid)) + { + switch (await attachmentMigrator.MigrateAttachment(attachmentGuid, $"__{fieldName}", cmsSite.SiteID)) + { + case MigrateAttachmentResultMediaFile { Success: true, MediaFileInfo: { } x }: + { + mfis = [new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize }]; + hasMigratedAsset = true; + logger.LogTrace("MediaFile migrated from attachment '{Field}': '{Value}' (parsed)", fieldName, attachmentGuid); + break; + } + case MigrateAttachmentResultContentItem { Success: true, ContentItemGuid: { } contentItemGuid }: + { + mfis = + [ + new ContentItemReference { Identifier = contentItemGuid } + ]; + hasMigratedAsset = true; + logger.LogTrace("Content item migrated from attachment '{Field}': '{Value}' to {ContentItemGUID}", fieldName, attachmentGuid, contentItemGuid); + break; + } + default: + { + logger.LogTrace("'{FieldName}' UserControlForFile Success={Success} AttachmentGUID={attachmentGuid}", fieldName, false, attachmentGuid); + break; + } + } + } + else + { + logger.LogTrace("'{FieldName}' UserControlForFile AttachmentGUID={Value}", fieldName, sourceValue); + } + + break; + } + case { UserControlForDocAttachments: true }: + { + // new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize } + if (documentId is { } docId) + { + var mfisl = new List(); + await foreach (var migResult in attachmentMigrator.MigrateGroupedAttachments(docId, field.Guid, field.Name)) + { + switch (migResult) + { + case MigrateAttachmentResultMediaFile { Success: true, MediaFileInfo: { } x }: + { + mfisl.Add(new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize }); + hasMigratedAsset = true; + break; + } + case MigrateAttachmentResultContentItem { Success: true, ContentItemGuid: { } contentItemGuid }: + { + mfis = + [ + new ContentItemReference { Identifier = contentItemGuid } + ]; + hasMigratedAsset = true; + logger.LogTrace("Content item migrated from document '{DocumentID}' attachment '{FiledName}' to {ContentItemGUID}", docId, field.Name, contentItemGuid); + break; + } + default: + { + hasMigratedAsset = false; + break; + } + } + } + + mfis = mfisl; + } + else + { + logger.LogTrace("'{FieldName}' UserControlForDocAttachments DocumentID={Value}", fieldName, documentId); + } + + break; + } + + default: + break; + } + } + else + { + logger.LogWarning("Unable to map value based on selected migration, value: '{Value}'", sourceValue); + return new FieldMigrationResult(false, null); + } + + if (hasMigratedAsset && mfis is { Count: > 0 }) + { + return new FieldMigrationResult(true, SerializationHelper.Serialize(mfis)); + } + else if (DBNull.Value.Equals(sourceValue)) + { + return new FieldMigrationResult(true, null); + } + else + { + logger.LogTrace("No assets migrated for '{FieldName}', value: '{Value}'", fieldName, sourceValue); + return new FieldMigrationResult(false, null); + } + } + + public void MigrateFieldDefinition(FormDefinitionPatcher formDefinitionPatcher, XElement field, XAttribute? columnTypeAttr, string fieldDescriptor) + { + columnTypeAttr?.SetValue(configuration.MigrateMediaToMediaLibrary ? FieldDataType.Assets : FieldDataType.ContentItemReference); + + var settings = field.EnsureElement(FormDefinitionPatcher.FieldElemSettings); + settings.EnsureElement(FormDefinitionPatcher.SettingsElemControlname, e => e.Value = configuration.MigrateMediaToMediaLibrary ? FormComponents.AdminAssetSelectorComponent : FormComponents.AdminContentItemSelectorComponent); + if (configuration.MigrateMediaToMediaLibrary) + { + settings.EnsureElement(FormDefinitionPatcher.SettingsMaximumassets, maxAssets => maxAssets.Value = FormDefinitionPatcher.SettingsMaximumassetsFallback); + } + } +} diff --git a/Migration.Tool.Extensions/Migration.Tool.Extensions.csproj b/Migration.Tool.Extensions/Migration.Tool.Extensions.csproj new file mode 100644 index 00000000..bf91b42d --- /dev/null +++ b/Migration.Tool.Extensions/Migration.Tool.Extensions.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/Migration.Tool.Extensions/README.md b/Migration.Tool.Extensions/README.md new file mode 100644 index 00000000..4b5577f4 --- /dev/null +++ b/Migration.Tool.Extensions/README.md @@ -0,0 +1,81 @@ +## Custom migrations + +Samples: +- `Migration.Tool.Extensions/CommunityMigrations/SampleTextMigration.cs` contains simplest implementation for migration of text fields +- `Migration.Tool.Extensions/DefaultMigrations/AssetMigration.cs` contains real world migration of assets (complex example) + +To create custom migration: +- create new file in `Migration.Tool.Extensions/CommunityMigrations` (directory if you need more files for single migration) +- implement interface `Migration.Toolkit.KXP.Api.Services.CmsClass.IFieldMigration` + - implement property rank, set number bellow 100 000 - for example 5000 + - implement method shall migrate (if method returns true, migration will be used) + - implement `MigrateFieldDefinition`, where objective is to mutate argument `XElement field` that represents one particular field + - implement `MigrateValue` where goal is to return new migrated value derived from `object? sourceValue` +- finally register in `Migration.Tool.Extensions/ServiceCollectionExtensions.cs` as `Transient` dependency into service collection. For example `services.AddTransient()` + +## Custom class mappings for page types + +examples are `Migration.Tool.Extensions/ClassMappings/ClassMappingSample.cs` + +### Class remodeling sample + +demonstrated in method `AddSimpleRemodelingSample`, goal is to take single data class and change it to more suitable shape. + +### Class merge sample + +demonstrated in method `AddClassMergeExample`, goal is to take multiple data classes from source instance and define their relation to new class + +lets define new class: +```csharp +var m = new MultiClassMapping(targetClassName, target => +{ + target.ClassName = targetClassName; + target.ClassTableName = "ET_Event"; + target.ClassDisplayName = "ET - MY new transformed event"; + target.ClassType = ClassType.CONTENT_TYPE; + target.ClassContentTypeType = ClassContentTypeType.WEBSITE; +}); +``` + +define new primary key: +```csharp +m.BuildField("EventID").AsPrimaryKey(); +``` + +and finally lets define relations to fields: + +1) build field title +```csharp +// build new field +var title = m.BuildField("Title"); + +// map "EventTitle" field form source data class "_ET.Event1" also use it as template for target field +title.SetFrom("_ET.Event1", "EventTitle", true); +// map "EventTitle" field form source data class "_ET.Event2" +title.SetFrom("_ET.Event2", "EventTitle"); + +// patch field definition, in this case lets change field caption +title.WithFieldPatch(f => f.Caption = "Event title"); +``` + +2) in similar fashion map other fields + +3) if needed custom value conversion can be used +```csharp +var startDate = m.BuildField("StartDate"); +startDate.SetFrom("_ET.Event1", "EventDateStart", true); +// if needed use value conversion to adapt value +startDate.ConvertFrom("_ET.Event2", "EventStartDateAsText", false, + v => v?.ToString() is { } av && !string.IsNullOrWhiteSpace(av) ? DateTime.Parse(av) : null +); +startDate.WithFieldPatch(f => f.Caption = "Event start date"); +``` + +4) register class mapping to dependency injection ocntainer +```csharp +serviceCollection.AddSingleton(m); +``` + +### Inject and use reusable schema + +demonstrated in method `AddReusableSchemaIntegrationSample`, goal is to take single data class and assign reusable schema. \ No newline at end of file diff --git a/Migration.Tool.Extensions/ServiceCollectionExtensions.cs b/Migration.Tool.Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..8e93ddc6 --- /dev/null +++ b/Migration.Tool.Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; +using Migration.Tool.Extensions.ClassMappings; +using Migration.Tool.Extensions.CommunityMigrations; +using Migration.Tool.Extensions.DefaultMigrations; +using Migration.Toolkit.KXP.Api.Services.CmsClass; + +namespace Migration.Tool.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection UseCustomizations(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(); + + services.AddClassMergeExample(); + services.AddSimpleRemodelingSample(); + services.AddReusableSchemaIntegrationSample(); + return services; + } +} diff --git a/Migration.Toolkit.CLI/Migration.Toolkit.CLI.csproj b/Migration.Toolkit.CLI/Migration.Toolkit.CLI.csproj index a25da82e..ad457fe0 100644 --- a/Migration.Toolkit.CLI/Migration.Toolkit.CLI.csproj +++ b/Migration.Toolkit.CLI/Migration.Toolkit.CLI.csproj @@ -6,6 +6,7 @@ + diff --git a/Migration.Toolkit.CLI/Program.cs b/Migration.Toolkit.CLI/Program.cs index 8bb02253..4a2a0655 100644 --- a/Migration.Toolkit.CLI/Program.cs +++ b/Migration.Toolkit.CLI/Program.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; - +using Migration.Tool.Extensions; using Migration.Toolkit.CLI; using Migration.Toolkit.Common; using Migration.Toolkit.Common.Abstractions; @@ -113,6 +113,8 @@ services.UseKsToolkitCore(settings.MigrateMediaToMediaLibrary); +services.UseCustomizations(); + await using var conn = new SqlConnection(settings.KxConnectionString); try { diff --git a/Migration.Toolkit.Common/Builders/ClassMapper.cs b/Migration.Toolkit.Common/Builders/ClassMapper.cs new file mode 100644 index 00000000..4735f489 --- /dev/null +++ b/Migration.Toolkit.Common/Builders/ClassMapper.cs @@ -0,0 +1,130 @@ +using CMS.DataEngine; +using CMS.FormEngine; + +namespace Migration.Toolkit.Common.Builders; + +public interface IClassMapping +{ + string TargetClassName { get; } + + bool IsMatch(string sourceClassName); + void PatchTargetDataClass(DataClassInfo target); + ICollection SourceClassNames { get; } + string PrimaryKey { get; } + IList Mappings { get; } + IDictionary> TargetFieldPatchers { get; } + IFieldMapping? GetMapping(string targetColumnName, string sourceClassName); + + string? GetTargetFieldName(string sourceColumnName, string sourceClassName); + string GetSourceFieldName(string targetColumnName, string nodeClassClassName); + + void UseResusableSchema(string reusableSchemaName); + IList ReusableSchemaNames { get; } +} + +public interface IFieldMapping +{ + bool IsTemplate { get; } + string SourceFieldName { get; } + string SourceClassName { get; } + string TargetFieldName { get; } +} + +public record FieldMapping(string TargetFieldName, string SourceClassName, string SourceFieldName, bool IsTemplate) : IFieldMapping; + +public record FieldMappingWithConversion(string TargetFieldName, string SourceClassName, string SourceFieldName, bool IsTemplate, Func Converter) : IFieldMapping; + +public class MultiClassMapping(string targetClassName, Action classPatcher) : IClassMapping +{ + public void PatchTargetDataClass(DataClassInfo target) => classPatcher(target); + + ICollection IClassMapping.SourceClassNames => SourceClassNames; + IList IClassMapping.Mappings => Mappings; + IDictionary> IClassMapping.TargetFieldPatchers => TargetFieldPatchers; + public IDictionary> TargetFieldPatchers = new Dictionary>(); + + public IFieldMapping? GetMapping(string targetColumnName, string sourceClassName) => Mappings.SingleOrDefault(x => x.TargetFieldName.Equals(targetColumnName, StringComparison.InvariantCultureIgnoreCase) && x.SourceClassName.Equals(sourceClassName, StringComparison.InvariantCultureIgnoreCase)); + + public string? GetTargetFieldName(string sourceColumnName, string sourceClassName) => + Mappings.SingleOrDefault(x => x.SourceFieldName.Equals(sourceColumnName, StringComparison.InvariantCultureIgnoreCase) && x.SourceClassName.Equals(sourceClassName, StringComparison.InvariantCultureIgnoreCase)) switch + { + { } m => m.TargetFieldName, + _ => sourceColumnName + }; + + public string GetSourceFieldName(string targetColumnName, string sourceClassName) => GetMapping(targetColumnName, sourceClassName) switch + { + null => targetColumnName, + FieldMapping fm => fm.SourceFieldName, + FieldMappingWithConversion fm => fm.SourceFieldName, + _ => targetColumnName + }; + + string IClassMapping.TargetClassName => targetClassName; + + public List Mappings { get; } = []; + public string PrimaryKey { get; set; } + + public HashSet SourceClassNames = new(StringComparer.InvariantCultureIgnoreCase); + + public FieldBuilder BuildField(string targetFieldName) + { + if (Mappings.Any(x => x.TargetFieldName.Equals(targetFieldName, StringComparison.InvariantCultureIgnoreCase))) + { + throw new InvalidOperationException($"Field mapping is already defined for field '{targetFieldName}'"); + } + return new FieldBuilder(this, targetFieldName); + } + + public bool IsMatch(string sourceClassName) => SourceClassNames.Contains(sourceClassName); + + public void UseResusableSchema(string reusableSchemaName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(reusableSchemaName); + + if (reusableSchemaNames.Contains(reusableSchemaName)) + { + throw new Exception($"The reusable Schema {reusableSchemaName} is already assigned"); + } + reusableSchemaNames.Add(reusableSchemaName); + } + + private readonly IList reusableSchemaNames = []; + IList IClassMapping.ReusableSchemaNames => reusableSchemaNames; +} + +public class FieldBuilder(MultiClassMapping multiClassMapping, string targetFieldName) +{ + private IFieldMapping? currentFieldMapping; + + public FieldBuilder SetFrom(string sourceClassName, string sourceFieldName, bool isTemplate = false) + { + currentFieldMapping = new FieldMapping(targetFieldName, sourceClassName, sourceFieldName, isTemplate); + multiClassMapping.Mappings.Add(currentFieldMapping); + multiClassMapping.SourceClassNames.Add(sourceClassName); + return this; + } + + public FieldBuilder ConvertFrom(string sourceClassName, string sourceFieldName, bool isTemplate, Func converter) + { + currentFieldMapping = new FieldMappingWithConversion(targetFieldName, sourceClassName, sourceFieldName, isTemplate, converter); + multiClassMapping.Mappings.Add(currentFieldMapping); + multiClassMapping.SourceClassNames.Add(sourceClassName); + return this; + } + + public FieldBuilder WithFieldPatch(Action fieldInfoPatcher) + { + if (!multiClassMapping.TargetFieldPatchers.TryAdd(targetFieldName, fieldInfoPatcher)) + { + throw new InvalidOperationException($"Target field mapper can be dined only once for each field, field '{targetFieldName}' has one already defined"); + } + return this; + } + + public MultiClassMapping AsPrimaryKey() + { + multiClassMapping.PrimaryKey = targetFieldName; + return multiClassMapping; + } +} diff --git a/Migration.Toolkit.Common/Builders/ReusableSchemaBuilder.cs b/Migration.Toolkit.Common/Builders/ReusableSchemaBuilder.cs new file mode 100644 index 00000000..4db61bf1 --- /dev/null +++ b/Migration.Toolkit.Common/Builders/ReusableSchemaBuilder.cs @@ -0,0 +1,93 @@ +using CMS.FormEngine; + +namespace Migration.Toolkit.Common.Builders; + +public record SourceFieldIdentifier(string ClassName, string FieldName); + +public interface IReusableSchemaBuilder +{ + string SchemaName { get; } + string SchemaDisplayName { get; } + string? SchemaDescription { get; } + IList FieldBuilders { get; } + IReusableFieldBuilder BuildField(string targetFieldName); + void AssertIsValid(); +} + +public class ReusableSchemaBuilder(string schemaName, string displayName, string? schemaDescription) : IReusableSchemaBuilder +{ + public string SchemaName { get; } = schemaName; + public string SchemaDisplayName { get; } = displayName; + public string? SchemaDescription { get; } = schemaDescription; + public IList FieldBuilders { get; set; } = []; + + public IReusableFieldBuilder BuildField(string targetFieldName) + { + if (FieldBuilders.Any(fb => fb.TargetFieldName.Equals(targetFieldName, StringComparison.InvariantCultureIgnoreCase))) + { + throw new InvalidOperationException($"Target field '{targetFieldName}' already exists"); + } + + var newFieldBuilder = new ReusableFieldBuilder(targetFieldName); + FieldBuilders.Add(newFieldBuilder); + return newFieldBuilder; + } + + public void AssertIsValid() + { + ArgumentException.ThrowIfNullOrWhiteSpace(SchemaName, nameof(SchemaName)); + ArgumentException.ThrowIfNullOrWhiteSpace(SchemaDisplayName, nameof(SchemaDisplayName)); + + foreach (var reusableFieldBuilder in FieldBuilders) + { + reusableFieldBuilder.AssertIsValid(); + } + } +} + +public interface IReusableFieldBuilder +{ + string TargetFieldName { get; } + Func? Factory { get; } + SourceFieldIdentifier? SourceFieldIdentifier { get; } + + void AssertIsValid(); + IReusableFieldBuilder WithFactory(Func formFieldFactory); + IReusableFieldBuilder CreateFrom(string sourceClassName, string sourceFieldName); +} + +public class ReusableFieldBuilder(string targetFieldName) : IReusableFieldBuilder +{ + public string TargetFieldName { get; } = targetFieldName; + public Func? Factory { get; private set; } + public SourceFieldIdentifier? SourceFieldIdentifier { get; private set; } + + public void AssertIsValid() + { + if (Factory == null && SourceFieldIdentifier == null) + { + throw new NullReferenceException($"Reusable field builder is not valid for field '{TargetFieldName}' call 'WithFactory' or 'CreateFrom' to define source of field value"); + } + + if (Factory != null && SourceFieldIdentifier != null) + { + throw new InvalidOperationException($"Reusable field builder is not valid for field '{TargetFieldName}' you cannot call both 'WithFactory' and 'CreateFrom' to define source of field value"); + } + } + + public IReusableFieldBuilder WithFactory(Func formFieldFactory) + { + ArgumentNullException.ThrowIfNull(formFieldFactory); + Factory = formFieldFactory; + return this; + } + + public IReusableFieldBuilder CreateFrom(string sourceClassName, string sourceFieldName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceClassName, nameof(sourceClassName)); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceFieldName, nameof(sourceFieldName)); + + SourceFieldIdentifier = new SourceFieldIdentifier(sourceClassName, sourceFieldName); + return this; + } +} diff --git a/Migration.Toolkit.Common/Helpers/GuidHelper.cs b/Migration.Toolkit.Common/Helpers/GuidHelper.cs index 5aa2ac58..d5919945 100644 --- a/Migration.Toolkit.Common/Helpers/GuidHelper.cs +++ b/Migration.Toolkit.Common/Helpers/GuidHelper.cs @@ -10,6 +10,7 @@ public static class GuidHelper public static readonly Guid GuidNsDocumentNameField = new("8935FCE5-1BDC-4677-A4CA-6DFD32F65A0F"); public static readonly Guid GuidNsAsset = new("9CC6DE90-8993-42D8-B4C1-1429B2F780A2"); public static readonly Guid GuidNsFolder = new("E21255AC-70F3-4A95-881A-E4AD908AF27C"); + public static readonly Guid GuidNsDataClass = new("E21255AC-70F3-4A95-881A-E4AD908AF27C"); public static Guid CreateWebPageUrlPathGuid(string hash) => GuidV5.NewNameBased(GuidNsWebPageUrlPathInfo, hash); public static Guid CreateReusableSchemaGuid(string name) => GuidV5.NewNameBased(GuidNsReusableSchema, name); @@ -18,7 +19,8 @@ public static class GuidHelper public static Guid CreateTaxonomyGuid(string name) => GuidV5.NewNameBased(GuidNsTaxonomy, name); public static Guid CreateDocumentNameFieldGuid(string name) => GuidV5.NewNameBased(GuidNsDocumentNameField, name); public static Guid CreateAssetGuid(Guid newMediaFileGuid, string contentLanguageCode) => GuidV5.NewNameBased(GuidNsAsset, $"{newMediaFileGuid}|{contentLanguageCode}"); - public static Guid CreateFolderGuid(string path) => GuidV5.NewNameBased(GuidNsDocumentNameField, path); + public static Guid CreateFolderGuid(string path) => GuidV5.NewNameBased(GuidNsFolder, path); + public static Guid CreateDataClassGuid(string key) => GuidV5.NewNameBased(GuidNsDataClass, key); public static readonly Guid GuidNsLibraryFallback = new("8935FCE5-1BDC-4677-A4CA-6DFD32F65A0F"); diff --git a/Migration.Toolkit.Common/Helpers/SerializationHelper.cs b/Migration.Toolkit.Common/Helpers/SerializationHelper.cs index 1c538d76..154966be 100644 --- a/Migration.Toolkit.Common/Helpers/SerializationHelper.cs +++ b/Migration.Toolkit.Common/Helpers/SerializationHelper.cs @@ -11,6 +11,8 @@ public class SerializationHelper public static string SerializeOnlyNonComplexProperties(T obj) => JsonConvert.SerializeObject(obj, Formatting.Indented, new JsonSerializerSettings { ContractResolver = new ShouldSerializeContractResolver(), ReferenceLoopHandling = ReferenceLoopHandling.Ignore, DefaultValueHandling = DefaultValueHandling.Ignore, MaxDepth = 1 }); + public static string Serialize(T obj) => JsonConvert.SerializeObject(obj); + public class ShouldSerializeContractResolver : DefaultContractResolver { public static readonly ShouldSerializeContractResolver Instance = new(); diff --git a/Migration.Toolkit.KXP.Api/DependencyInjectionExtensions.cs b/Migration.Toolkit.KXP.Api/DependencyInjectionExtensions.cs index ecfea274..23292b11 100644 --- a/Migration.Toolkit.KXP.Api/DependencyInjectionExtensions.cs +++ b/Migration.Toolkit.KXP.Api/DependencyInjectionExtensions.cs @@ -18,8 +18,10 @@ public static IServiceCollection UseKxpApi(this IServiceCollection services, ICo SystemContext.WebApplicationPhysicalPath = applicationPhysicalPath; } + + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(s => (s.GetService() as FieldMigrationService)!); services.AddSingleton(); services.AddSingleton(); diff --git a/Migration.Toolkit.KXP.Api/Services/CmsClass/FieldMigrationService.cs b/Migration.Toolkit.KXP.Api/Services/CmsClass/FieldMigrationService.cs index 0cb65327..2720dac8 100644 --- a/Migration.Toolkit.KXP.Api/Services/CmsClass/FieldMigrationService.cs +++ b/Migration.Toolkit.KXP.Api/Services/CmsClass/FieldMigrationService.cs @@ -7,13 +7,16 @@ namespace Migration.Toolkit.KXP.Api.Services.CmsClass; public class FieldMigrationService // shall be singleton to cache necessary data + : IFieldMigrationService { private readonly ILogger logger; + private readonly IList fieldMigrations; private readonly FieldMigration[] userDefinedMigrations; - public FieldMigrationService(ToolkitConfiguration configuration, ILogger logger) + public FieldMigrationService(ToolkitConfiguration configuration, ILogger logger, IEnumerable fieldMigrations) { this.logger = logger; + this.fieldMigrations = fieldMigrations.OrderBy(x => x.Rank).ToList(); var allUserDefinedMigrations = configuration.OptInFeatures?.CustomMigration?.FieldMigrations?.Select(fm => new FieldMigration( @@ -24,12 +27,21 @@ public FieldMigrationService(ToolkitConfiguration configuration, ILogger(); + ).ToArray() ?? []; userDefinedMigrations = allUserDefinedMigrations; } - public FieldMigration? GetFieldMigration(string sourceDataType, string? sourceFormControl, string? fieldName) + public IFieldMigration? GetFieldMigration(FieldMigrationContext fieldMigrationContext) { + foreach (var fieldMigrator in fieldMigrations) + { + if (fieldMigrator.ShallMigrate(fieldMigrationContext)) + { + return fieldMigrator; + } + } + + (string? sourceDataType, string? sourceFormControl, string? fieldName, _) = fieldMigrationContext; if (sourceFormControl == null) { logger.LogDebug("Source field has no control defined '{SourceDataType}', field '{FieldName}'", sourceDataType, fieldName); diff --git a/Migration.Toolkit.KXP.Api/Services/CmsClass/FormDefinitionPatcher.cs b/Migration.Toolkit.KXP.Api/Services/CmsClass/FormDefinitionPatcher.cs index aeb6e738..f9b86665 100644 --- a/Migration.Toolkit.KXP.Api/Services/CmsClass/FormDefinitionPatcher.cs +++ b/Migration.Toolkit.KXP.Api/Services/CmsClass/FormDefinitionPatcher.cs @@ -9,29 +9,29 @@ namespace Migration.Toolkit.KXP.Api.Services.CmsClass; public class FormDefinitionPatcher { - private const string CategoryElem = "category"; - private const string CategoryAttrName = FieldAttrName; - private const string FieldAttrColumn = "column"; - private const string FieldAttrColumntype = "columntype"; - private const string FieldAttrEnabled = "enabled"; - private const string FieldAttrGuid = "guid"; - private const string FieldAttrIspk = "isPK"; - private const string FieldAttrName = "name"; - private const string FieldAttrSize = "size"; - private const int FieldAttrSizeZero = 0; - private const string FieldAttrSystem = "system"; - private const string FieldAttrVisible = "visible"; - private const string FieldElem = "field"; - private const string FieldElemProperties = "properties"; - private const string FieldElemSettings = "settings"; - private const string PropertiesElemDefaultvalue = "defaultvalue"; - private const string SettingsElemControlname = "controlname"; - private const string SettingsMaximumassets = "MaximumAssets"; - private const string SettingsMaximumassetsFallback = "99"; - private const string SettingsMaximumpages = "MaximumPages"; - private const string SettingsMaximumpagesFallback = "99"; - private const string SettingsRootpath = "RootPath"; - private const string SettingsRootpathFallback = "/"; + public const string CategoryElem = "category"; + public const string CategoryAttrName = FieldAttrName; + public const string FieldAttrColumn = "column"; + public const string FieldAttrColumntype = "columntype"; + public const string FieldAttrEnabled = "enabled"; + public const string FieldAttrGuid = "guid"; + public const string FieldAttrIspk = "isPK"; + public const string FieldAttrName = "name"; + public const string FieldAttrSize = "size"; + public const int FieldAttrSizeZero = 0; + public const string FieldAttrSystem = "system"; + public const string FieldAttrVisible = "visible"; + public const string FieldElem = "field"; + public const string FieldElemProperties = "properties"; + public const string FieldElemSettings = "settings"; + public const string PropertiesElemDefaultvalue = "defaultvalue"; + public const string SettingsElemControlname = "controlname"; + public const string SettingsMaximumassets = "MaximumAssets"; + public const string SettingsMaximumassetsFallback = "99"; + public const string SettingsMaximumpages = "MaximumPages"; + public const string SettingsMaximumpagesFallback = "99"; + public const string SettingsRootpath = "RootPath"; + public const string SettingsRootpathFallback = "/"; private readonly IReadOnlySet allowedFieldAttributes = new HashSet([ // taken from FormFieldInfo.GetAttributes() method @@ -60,7 +60,7 @@ public class FormDefinitionPatcher private readonly bool classIsDocumentType; private readonly bool classIsForm; private readonly bool discardSysFields; - private readonly FieldMigrationService fieldMigrationService; + private readonly IFieldMigrationService fieldMigrationService; private readonly string formDefinitionXml; private readonly ILogger logger; @@ -68,7 +68,7 @@ public class FormDefinitionPatcher public FormDefinitionPatcher(ILogger logger, string formDefinitionXml, - FieldMigrationService fieldMigrationService, + IFieldMigrationService fieldMigrationService, bool classIsForm, bool classIsDocumentType, bool discardSysFields, @@ -162,7 +162,7 @@ public void PatchFields() public string? GetPatched() => xDoc.Root?.ToString(); - private void PatchField(XElement field) + public void PatchField(XElement field) { var columnAttr = field.Attribute(FieldAttrColumn); var systemAttr = field.Attribute(FieldAttrSystem); @@ -211,36 +211,48 @@ private void PatchField(XElement field) var controlNameElem = field.XPathSelectElement($"{FieldElemSettings}/{SettingsElemControlname}"); string? controlName = controlNameElem?.Value; - if (fieldMigrationService.GetFieldMigration(columnType, controlName, columnAttr?.Value) is var (_, targetDataType, _, targetFormComponent, actions, _)) + var fieldMigrationContext = new FieldMigrationContext(columnType, controlName, columnAttr?.Value, new EmptySourceObjectContext()); + switch (fieldMigrationService.GetFieldMigration(fieldMigrationContext)) { - logger.LogDebug("Field {FieldDescriptor} DataType: {SourceDataType} => {TargetDataType}", fieldDescriptor, columnType, targetDataType); - columnTypeAttr?.SetValue(targetDataType); - switch (targetFormComponent) + case FieldMigration(_, var targetDataType, _, var targetFormComponent, var actions, _): { - case TfcDirective.DoNothing: - logger.LogDebug("Field {FieldDescriptor} ControlName: Tca:{TcaDirective}", fieldDescriptor, targetFormComponent); - PerformActionsOnField(field, fieldDescriptor, actions); - break; - case TfcDirective.Clear: - logger.LogDebug("Field {FieldDescriptor} ControlName: Tca:{TcaDirective}", fieldDescriptor, targetFormComponent); - field.RemoveNodes(); - visibleAttr?.SetValue(false); - break; - case TfcDirective.CopySourceControl: - logger.LogDebug("Field {FieldDescriptor} ControlName: Tca:{TcaDirective} => {ControlName}", fieldDescriptor, targetFormComponent, controlName); - controlNameElem?.SetValue(controlName); - PerformActionsOnField(field, fieldDescriptor, actions); - break; - default: + logger.LogDebug("Field {FieldDescriptor} DataType: {SourceDataType} => {TargetDataType}", fieldDescriptor, columnType, targetDataType); + columnTypeAttr?.SetValue(targetDataType); + switch (targetFormComponent) { - logger.LogDebug("Field {FieldDescriptor} ControlName: Tca:NONE => from control '{ControlName}' => {TargetFormComponent}", fieldDescriptor, controlName, targetFormComponent); - controlNameElem?.SetValue(targetFormComponent); - PerformActionsOnField(field, fieldDescriptor, actions); - break; + case TfcDirective.DoNothing: + logger.LogDebug("Field {FieldDescriptor} ControlName: Tca:{TcaDirective}", fieldDescriptor, targetFormComponent); + PerformActionsOnField(field, fieldDescriptor, actions); + break; + case TfcDirective.Clear: + logger.LogDebug("Field {FieldDescriptor} ControlName: Tca:{TcaDirective}", fieldDescriptor, targetFormComponent); + field.RemoveNodes(); + visibleAttr?.SetValue(false); + break; + case TfcDirective.CopySourceControl: + logger.LogDebug("Field {FieldDescriptor} ControlName: Tca:{TcaDirective} => {ControlName}", fieldDescriptor, targetFormComponent, controlName); + controlNameElem?.SetValue(controlName); + PerformActionsOnField(field, fieldDescriptor, actions); + break; + default: + { + logger.LogDebug("Field {FieldDescriptor} ControlName: Tca:NONE => from control '{ControlName}' => {TargetFormComponent}", fieldDescriptor, controlName, targetFormComponent); + controlNameElem?.SetValue(targetFormComponent); + PerformActionsOnField(field, fieldDescriptor, actions); + break; + } } + break; + } + case { } fieldMigration when fieldMigration.ShallMigrate(fieldMigrationContext): + { + fieldMigration.MigrateFieldDefinition(this, field, columnTypeAttr, fieldDescriptor); + break; } - } + default: + break; + } if (!classIsForm && !classIsDocumentType) { @@ -308,7 +320,7 @@ private void PatchField(XElement field) if (classIsForm || classIsDocumentType) { - if (field.Attribute(FieldAttrVisible) is { } visible) + if (field.Attribute(FieldAttrVisible) is { } visible && field.Attribute(FieldAttrEnabled) is null) { field.Add(new XAttribute(FieldAttrEnabled, visible.Value)); logger.LogDebug("Set field '{Field}' attribute '{Attribute}' to value '{Value}' from attribute '{SourceAttribute}'", fieldDescriptor, FieldAttrEnabled, visible, FieldAttrVisible); diff --git a/Migration.Toolkit.KXP.Api/Services/CmsClass/FormFieldMappingModel.cs b/Migration.Toolkit.KXP.Api/Services/CmsClass/FormFieldMappingModel.cs index 90139650..6344ea03 100644 --- a/Migration.Toolkit.KXP.Api/Services/CmsClass/FormFieldMappingModel.cs +++ b/Migration.Toolkit.KXP.Api/Services/CmsClass/FormFieldMappingModel.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using System.Xml.Linq; using CMS.DataEngine; using CMS.OnlineForms; using Migration.Toolkit.Common; @@ -17,7 +18,49 @@ public record DataTypeMigrationModel( [property: Obsolete("Legacy mode is no longer supported")] string[] SupportedInKxpLegacyMode ); -public record FieldMigration(string SourceDataType, string TargetDataType, string SourceFormControl, string? TargetFormComponent, string[]? Actions = null, Regex? FieldNameRegex = null); +public interface ISourceObjectContext; + +public record EmptySourceObjectContext : ISourceObjectContext; +public record FieldMigrationContext(string SourceDataType, string? SourceFormControl, string? FieldName, ISourceObjectContext SourceObjectContext); +public record FieldMigrationResult(bool Success, object? MigratedValue); +public interface IFieldMigration +{ + /// + /// custom migrations are sorted by this number, first encountered migration wins. Values higher than 100 000 are set to default migrations, set number bellow 100 000 for custom migrations + /// + int Rank { get; } + + /// + /// Methods determines if this migration is usable in context + /// + /// Expect multiple context types: for pages, for data class + /// + bool ShallMigrate(FieldMigrationContext context); + + /// + /// Performs migration of FormField, result is mutated property field + /// + /// Helper class for execution of common functionalities + /// Field for migration + /// field type - in xml "columntype" + /// field name or field GUID if field name is not specified + void MigrateFieldDefinition(FormDefinitionPatcher formDefinitionPatcher, XElement field, XAttribute? columnTypeAttr, string fieldDescriptor); + /// + /// Performs migration of field value + /// + /// Value from source instance for migration directly from database reader (DBNull may be encountered) + /// Context for pages + /// If migration of field succeeds, returns success and migrated value. If not, returns false as success and null reference as value + Task MigrateValue(object? sourceValue, FieldMigrationContext context); +} + +public record FieldMigration(string SourceDataType, string TargetDataType, string SourceFormControl, string? TargetFormComponent, string[]? Actions = null, Regex? FieldNameRegex = null) : IFieldMigration +{ + public int Rank => 100_000; + public bool ShallMigrate(FieldMigrationContext context) => throw new NotImplementedException(); + public Task MigrateValue(object? sourceValue, FieldMigrationContext context) => throw new NotImplementedException(); + public void MigrateFieldDefinition(FormDefinitionPatcher formDefinitionPatcher, XElement field, XAttribute? columnTypeAttr, string fieldDescriptor) => throw new NotImplementedException(); +} /// /// Tca = target control action @@ -83,7 +126,7 @@ public static void PrepareFieldMigrations(ToolkitConfiguration configuration) new FieldMigration(KsFieldDataType.Xml, FieldDataType.Xml, SfcDirective.CatchAnyNonMatching, FormComponents.AdminNumberWithLabelComponent), new FieldMigration(KsFieldDataType.DocRelationships, FieldDataType.WebPages, SfcDirective.CatchAnyNonMatching, FormComponents.Kentico_Xperience_Admin_Websites_WebPageSelectorComponent, [TcaDirective.ConvertToPages]), - new FieldMigration(KsFieldDataType.TimeSpan, FieldDataType.TimeSpan, SfcDirective.CatchAnyNonMatching, FormComponents.AdminTextInputComponent, [TcaDirective.ConvertToPages]), + new FieldMigration(KsFieldDataType.TimeSpan, FieldDataType.TimeSpan, SfcDirective.CatchAnyNonMatching, FormComponents.AdminTextInputComponent, []), new FieldMigration(KsFieldDataType.BizFormFile, BizFormUploadFile.DATATYPE_FORMFILE, SfcDirective.CatchAnyNonMatching, FormComponents.MvcFileUploaderComponent, []) ]); diff --git a/Migration.Toolkit.KXP.Api/Services/CmsClass/IFieldMigrationService.cs b/Migration.Toolkit.KXP.Api/Services/CmsClass/IFieldMigrationService.cs new file mode 100644 index 00000000..4fd74b84 --- /dev/null +++ b/Migration.Toolkit.KXP.Api/Services/CmsClass/IFieldMigrationService.cs @@ -0,0 +1,6 @@ +namespace Migration.Toolkit.KXP.Api.Services.CmsClass; + +public interface IFieldMigrationService +{ + IFieldMigration? GetFieldMigration(FieldMigrationContext fieldMigrationContext); +} diff --git a/Migration.Toolkit.sln b/Migration.Toolkit.sln index 705245a9..9082ae17 100644 --- a/Migration.Toolkit.sln +++ b/Migration.Toolkit.sln @@ -40,6 +40,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "KVA", "KVA", "{296DCE8F-D00 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Migration.Toolkit.Source", "KVA\Migration.Toolkit.Source\Migration.Toolkit.Source.csproj", "{C565B800-032C-44E4-A913-659C24E9A3EC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Migration.Tool.Extensions", "Migration.Tool.Extensions\Migration.Tool.Extensions.csproj", "{2D2E8533-2C17-471F-BB9F-04DEEDE7F80B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -101,6 +103,10 @@ Global {C565B800-032C-44E4-A913-659C24E9A3EC}.Debug|Any CPU.Build.0 = Debug|Any CPU {C565B800-032C-44E4-A913-659C24E9A3EC}.Release|Any CPU.ActiveCfg = Release|Any CPU {C565B800-032C-44E4-A913-659C24E9A3EC}.Release|Any CPU.Build.0 = Release|Any CPU + {2D2E8533-2C17-471F-BB9F-04DEEDE7F80B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2D2E8533-2C17-471F-BB9F-04DEEDE7F80B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D2E8533-2C17-471F-BB9F-04DEEDE7F80B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2D2E8533-2C17-471F-BB9F-04DEEDE7F80B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8094319D-85E5-430C-BBC0-345C9AA8CBF2} = {F823E280-75D2-4C82-825E-CB6FB00E7067}