diff --git a/.gitattributes b/.gitattributes index 6c63a529..c9873176 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,5 @@ # Auto detect text files and perform LF normalization -* text=auto +* text=CRLF *.cs text=CRLF diff=csharp *.html text diff=html diff --git a/KVA/Migration.Tool.Source/Handlers/MigratePageTypesCommandHandler.cs b/KVA/Migration.Tool.Source/Handlers/MigratePageTypesCommandHandler.cs index 82fbb401..c791d10b 100644 --- a/KVA/Migration.Tool.Source/Handlers/MigratePageTypesCommandHandler.cs +++ b/KVA/Migration.Tool.Source/Handlers/MigratePageTypesCommandHandler.cs @@ -103,27 +103,30 @@ public async Task Handle(MigratePageTypesCommand request, Cancell 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)) + foreach (var cmm in cmml) { - // part of reusable schema - continue; - } + 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 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; + 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; + } } + //var cmm = cmml.FirstOrDefault() ?? throw new InvalidOperationException(); } if (!hasFieldsAlready) @@ -151,6 +154,11 @@ public async Task Handle(MigratePageTypesCommand request, Cancell foreach (string sourceClassName in classMapping.SourceClassNames) { + if (newDt.ClassContentTypeType is ClassContentTypeType.REUSABLE) + { + continue; + } + var sourceClass = cmsClasses.First(c => c.ClassName.Equals(sourceClassName, StringComparison.InvariantCultureIgnoreCase)); foreach (var cmsClassSite in modelFacade.SelectWhere("ClassId = @classId", new SqlParameter("classId", sourceClass.ClassID))) { @@ -237,26 +245,29 @@ public async Task Handle(MigratePageTypesCommand request, Cancell var kxoDataClass = kxpClassFacade.GetClass(ksClass.ClassGUID); protocol.FetchedTarget(kxoDataClass); - if (SaveUsingKxoApi(ksClass, kxoDataClass) is { } targetClassId) + if (SaveUsingKxoApi(ksClass, kxoDataClass) is { } targetClass) { - foreach (var cmsClassSite in modelFacade.SelectWhere("ClassID = @classId", new SqlParameter("classId", ksClass.ClassID))) + if (targetClass.ClassContentTypeType is ClassContentTypeType.WEBSITE) { - if (modelFacade.SelectById(cmsClassSite.SiteID) is { SiteGUID: var siteGuid }) + foreach (var cmsClassSite in modelFacade.SelectWhere("ClassID = @classId", new SqlParameter("classId", ksClass.ClassID))) { - if (ChannelInfoProvider.ProviderObject.Get(siteGuid) is { ChannelID: var channelId }) + if (modelFacade.SelectById(cmsClassSite.SiteID) is { SiteGUID: var siteGuid }) { - var info = new ContentTypeChannelInfo { ContentTypeChannelChannelID = channelId, ContentTypeChannelContentTypeID = targetClassId }; - ContentTypeChannelInfoProvider.ProviderObject.Set(info); + if (ChannelInfoProvider.ProviderObject.Get(siteGuid) is { ChannelID: var channelId }) + { + var info = new ContentTypeChannelInfo { ContentTypeChannelChannelID = channelId, ContentTypeChannelContentTypeID = targetClass.ClassID }; + ContentTypeChannelInfoProvider.ProviderObject.Set(info); + } + else + { + logger.LogWarning("Channel for site with SiteGUID '{SiteGuid}' not found", siteGuid); + } } else { - logger.LogWarning("Channel for site with SiteGUID '{SiteGuid}' not found", siteGuid); + logger.LogWarning("Source site with SiteID '{SiteId}' not found", cmsClassSite.SiteID); } } - else - { - logger.LogWarning("Source site with SiteID '{SiteId}' not found", cmsClassSite.SiteID); - } } } } @@ -332,7 +343,7 @@ private async Task MigratePageTemplateConfigurations() } } - private int? SaveUsingKxoApi(ICmsClass ksClass, DataClassInfo kxoDataClass) + private DataClassInfo? SaveUsingKxoApi(ICmsClass ksClass, DataClassInfo kxoDataClass) { var mapped = dataClassMapper.Map(ksClass, kxoDataClass); protocol.MappedTarget(mapped); @@ -360,7 +371,7 @@ private async Task MigratePageTemplateConfigurations() dataClassInfo.ClassID ); - return dataClassInfo.ClassID; + return dataClassInfo; } } catch (Exception ex) diff --git a/KVA/Migration.Tool.Source/Handlers/MigratePagesCommandHandler.cs b/KVA/Migration.Tool.Source/Handlers/MigratePagesCommandHandler.cs index 66f3b4b8..aa5608ba 100644 --- a/KVA/Migration.Tool.Source/Handlers/MigratePagesCommandHandler.cs +++ b/KVA/Migration.Tool.Source/Handlers/MigratePagesCommandHandler.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; using System.Diagnostics; - using CMS.ContentEngine; using CMS.ContentEngine.Internal; using CMS.Core; @@ -12,12 +11,9 @@ using CMS.Websites.Routing.Internal; using Kentico.Xperience.UMT.Model; using Kentico.Xperience.UMT.Services; - using MediatR; - using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; - using Migration.Tool.Common; using Migration.Tool.Common.Abstractions; using Migration.Tool.Common.Helpers; @@ -30,7 +26,6 @@ using Migration.Tool.Source.Model; using Migration.Tool.Source.Providers; using Migration.Tool.Source.Services; - using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -218,14 +213,13 @@ public async Task Handle(MigratePagesCommand request, Cancellatio var commonDataInfos = new List(); foreach (var umtModel in results) { - var result = await importer.ImportAsync(umtModel); - if (result is { Success: false }) - { - logger.LogError("Failed to import: {Exception}, {ValidationResults}", result.Exception, JsonConvert.SerializeObject(result.ModelValidationResults)); - } - - switch (result) + switch (await importer.ImportAsync(umtModel)) { + case { Success: false } result: + { + logger.LogError("Failed to import: {Exception}, {ValidationResults}", result.Exception, JsonConvert.SerializeObject(result.ModelValidationResults)); + break; + } case { Success: true, Imported: ContentItemCommonDataInfo ccid }: { commonDataInfos.Add(ccid); diff --git a/KVA/Migration.Tool.Source/Mappers/CmsClassMapper.cs b/KVA/Migration.Tool.Source/Mappers/CmsClassMapper.cs index 0413f96e..33d9ac95 100644 --- a/KVA/Migration.Tool.Source/Mappers/CmsClassMapper.cs +++ b/KVA/Migration.Tool.Source/Mappers/CmsClassMapper.cs @@ -25,7 +25,8 @@ public class CmsClassMapper( PrimaryKeyMappingContext primaryKeyMappingContext, IProtocol protocol, FieldMigrationService fieldMigrationService, - ModelFacade modelFacade + ModelFacade modelFacade, + ToolConfiguration configuration ) : EntityMapperBase(logger, primaryKeyMappingContext, protocol) @@ -188,7 +189,9 @@ protected override DataClassInfo MapInternal(ICmsClass source, DataClassInfo tar }: { target.ClassType = ClassType.CONTENT_TYPE; - target.ClassContentTypeType = ClassContentTypeType.WEBSITE; + target.ClassContentTypeType = configuration.ClassNamesConvertToContentHub.Contains(target.ClassName) + ? ClassContentTypeType.REUSABLE + : ClassContentTypeType.WEBSITE; target = PatchDataClassInfo(target, out string? oldPrimaryKeyName, out string? documentNameField); break; diff --git a/KVA/Migration.Tool.Source/Mappers/ContentItemMapper.cs b/KVA/Migration.Tool.Source/Mappers/ContentItemMapper.cs index 399ac595..231259b6 100644 --- a/KVA/Migration.Tool.Source/Mappers/ContentItemMapper.cs +++ b/KVA/Migration.Tool.Source/Mappers/ContentItemMapper.cs @@ -66,19 +66,22 @@ protected override IEnumerable MapInternal(CmsTreeMapperSource source 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; + DataClassInfo targetClassInfo = null; if (mapping != null) { - targetClassGuid = DataClassInfoProvider.ProviderObject.Get(mapping.TargetClassName)?.ClassGUID ?? throw new InvalidOperationException($"Unable to find target class '{mapping.TargetClassName}'"); + targetClassInfo = DataClassInfoProvider.ProviderObject.Get(mapping.TargetClassName) ?? throw new InvalidOperationException($"Unable to find target class '{mapping.TargetClassName}'"); + targetClassGuid = targetClassInfo.ClassGUID; } bool migratedAsContentFolder = sourceNodeClass.ClassName.Equals("cms.folder", StringComparison.InvariantCultureIgnoreCase) && !configuration.UseDeprecatedFolderPageType.GetValueOrDefault(false); var contentItemGuid = spoiledGuidContext.EnsureNodeGuid(cmsTree.NodeGUID, cmsTree.NodeSiteID, cmsTree.NodeID); + bool isMappedTypeReusable = (targetClassInfo?.ClassContentTypeType is ClassContentTypeType.REUSABLE) || configuration.ClassNamesConvertToContentHub.Contains(sourceNodeClass.ClassName); yield return new ContentItemModel { ContentItemGUID = contentItemGuid, ContentItemName = safeNodeName, - ContentItemIsReusable = false, // page is not reusable + ContentItemIsReusable = isMappedTypeReusable, ContentItemIsSecured = cmsTree.IsSecuredNode ?? false, ContentItemDataClassGuid = migratedAsContentFolder ? null : targetClassGuid, ContentItemChannelGuid = siteGuid @@ -350,16 +353,19 @@ protected override IEnumerable MapInternal(CmsTreeMapperSource source Debug.Assert(cmsTree.NodeLinkedNodeID == null, "cmsTree.NodeLinkedNodeId == null"); Debug.Assert(cmsTree.NodeLinkedNodeSiteID == null, "cmsTree.NodeLinkedNodeSiteId == null"); - yield return new WebPageItemModel + if (!isMappedTypeReusable) { - WebPageItemParentGuid = nodeParentGuid, // NULL => under root - WebPageItemGUID = contentItemGuid, - WebPageItemName = safeNodeName, - WebPageItemTreePath = treePath, - WebPageItemWebsiteChannelGuid = siteGuid, - WebPageItemContentItemGuid = contentItemGuid, - WebPageItemOrder = cmsTree.NodeOrder ?? 0 // 0 is nullish value - }; + yield return new WebPageItemModel + { + WebPageItemParentGuid = nodeParentGuid, // NULL => under root + WebPageItemGUID = contentItemGuid, + WebPageItemName = safeNodeName, + WebPageItemTreePath = treePath, + WebPageItemWebsiteChannelGuid = siteGuid, + WebPageItemContentItemGuid = contentItemGuid, + WebPageItemOrder = cmsTree.NodeOrder ?? 0 // 0 is nullish value + }; + } } private IEnumerable MigrateDraft(ICmsVersionHistory checkoutVersion, ICmsTree cmsTree, string sourceFormClassDefinition, string targetFormDefinition, Guid contentItemGuid, diff --git a/KVA/Migration.Tool.Source/Mappers/MediaFileInfoMapper.cs b/KVA/Migration.Tool.Source/Mappers/MediaFileInfoMapper.cs index 563469cb..e6f72265 100644 --- a/KVA/Migration.Tool.Source/Mappers/MediaFileInfoMapper.cs +++ b/KVA/Migration.Tool.Source/Mappers/MediaFileInfoMapper.cs @@ -47,9 +47,9 @@ ToolConfiguration toolConfiguration return new MediaFileInfo(); } - protected override MediaFileInfo MapInternal(MediaFileInfoMapperSource args, MediaFileInfo target, bool newInstance, MappingHelper mappingHelper, AddFailure addFailure) + protected override MediaFileInfo MapInternal(MediaFileInfoMapperSource source, MediaFileInfo target, bool newInstance, MappingHelper mappingHelper, AddFailure addFailure) { - (string fullMediaFilePath, var mediaFile, int targetLibraryId, var file, _, bool migrateOnlyMediaFileInfo, var safeMediaFileGuid) = args; + (string fullMediaFilePath, var mediaFile, int targetLibraryId, var file, _, bool migrateOnlyMediaFileInfo, var safeMediaFileGuid) = source; target.FileName = mediaFile.FileName; target.FileTitle = mediaFile.FileTitle; @@ -100,6 +100,7 @@ protected override MediaFileInfo MapInternal(MediaFileInfoMapperSource args, Med addFailure(HandbookReferences.MediaFileIsMissingOnSourceFilesystem .WithId(nameof(mediaFile.FileID), mediaFile.FileID) .WithData(new { mediaFile.FilePath, mediaFile.FileGUID, mediaFile.FileLibraryID, mediaFile.FileSiteID, SearchedPath = fullMediaFilePath }) + .WithSuggestion($"If you have a backup, copy it to the filesystem on path {source.FullMediaFilePath}. Otherwise, delete the media file from the source instance using admin web interface.") .AsFailure() ); } diff --git a/KVA/Migration.Tool.Source/Services/AttachmentMigratorToContentItem.cs b/KVA/Migration.Tool.Source/Services/AttachmentMigratorToContentItem.cs index be94568e..ae841add 100644 --- a/KVA/Migration.Tool.Source/Services/AttachmentMigratorToContentItem.cs +++ b/KVA/Migration.Tool.Source/Services/AttachmentMigratorToContentItem.cs @@ -131,7 +131,12 @@ public async Task MigrateAttachment(ICmsAttachment ksA if (ksAttachment.AttachmentBinary is null) { - logger.LogError("Binary data is null, cannot migrate attachment: {Attachment}", ksAttachment); + logger.LogError("Attachment binary data is null {Attachment} " + + "Option 1: Via admin web interface of your source instance navigate to the attachment and update the data. " + + "Option 2: Update the database directly - table CMS_Attachment, column AttachmentBinary. " + + "Option 3: Via admin web interface of your source instance remove all attachment references, then remove the attachment", + new { ksAttachment.AttachmentName, ksAttachment.AttachmentSiteID, ksAttachment.AttachmentID }); + throw new InvalidOperationException("Attachment data is null!"); } diff --git a/KVA/Migration.Tool.Source/Services/AttachmentMigratorToMediaLibrary.cs b/KVA/Migration.Tool.Source/Services/AttachmentMigratorToMediaLibrary.cs index cdbe85c8..d03c645c 100644 --- a/KVA/Migration.Tool.Source/Services/AttachmentMigratorToMediaLibrary.cs +++ b/KVA/Migration.Tool.Source/Services/AttachmentMigratorToMediaLibrary.cs @@ -214,7 +214,12 @@ public async Task MigrateAttachment(ICmsAttachment ksA return DummyUploadedFile.FromStream(ms, attachment.AttachmentMimeType, attachment.AttachmentSize, attachment.AttachmentName); } - logger.LogWarning("Attachment binary is null! {Attachment}", new { attachment.AttachmentName, attachment.AttachmentSiteID, attachment.AttachmentID }); + logger.LogError("Attachment binary data is null {Attachment} " + + "Option 1: Via admin web interface of your source instance navigate to the attachment and update the data. " + + "Option 2: Update the database directly - table CMS_Attachment, column AttachmentBinary. " + + "Option 3: Via admin web interface of your source instance remove all attachment references, then remove the attachment", + new { attachment.AttachmentName, attachment.AttachmentSiteID, attachment.AttachmentID }); + return null; } diff --git a/KVA/Migration.Tool.Source/Services/PageBuilderPatcher.cs b/KVA/Migration.Tool.Source/Services/PageBuilderPatcher.cs index 628da52e..43f604b4 100644 --- a/KVA/Migration.Tool.Source/Services/PageBuilderPatcher.cs +++ b/KVA/Migration.Tool.Source/Services/PageBuilderPatcher.cs @@ -41,7 +41,7 @@ public async Task PatchJsonDefinitions(int sourceSiteId, sourceInstanceContext.GetPageTemplateFormComponents(sourceSiteId, pageTemplateConfigurationObj.Identifier); if (pageTemplateConfigurationObj.Properties is { Count: > 0 }) { - bool ndp = await MigrateProperties(sourceSiteId, pageTemplateConfigurationObj.Properties, pageTemplateConfigurationFcs); + bool ndp = await MigrateProperties(sourceSiteId, pageTemplateConfigurationObj.Properties, pageTemplateConfigurationFcs, new Dictionary()); needsDeferredPatch = ndp || needsDeferredPatch; } @@ -92,8 +92,12 @@ private async Task WalkSections(int siteId, List sec logger.LogTrace("Walk section {TypeIdentifier}|{Identifier}", section.TypeIdentifier, section.Identifier); var sectionFcs = sourceInstanceContext.GetSectionFormComponents(siteId, section.TypeIdentifier); - bool ndp1 = await MigrateProperties(siteId, section.Properties, sectionFcs); - needsDeferredPatch = ndp1 || needsDeferredPatch; + + if (section.Properties is { Count: > 0 } properties) + { + bool ndp1 = await MigrateProperties(siteId, properties, sectionFcs, new Dictionary()); + needsDeferredPatch = ndp1 || needsDeferredPatch; + } if (section.Zones is { Count: > 0 }) { @@ -128,19 +132,32 @@ private async Task WalkWidgets(int siteId, List widge foreach (var widget in widgets) { logger.LogTrace("Walk widget {TypeIdentifier}|{Identifier}", widget.TypeIdentifier, widget.Identifier); + var widgetCompos = sourceInstanceContext.GetWidgetPropertyFormComponents(siteId, widget.TypeIdentifier); + var context = new WidgetMigrationContext(siteId); + var identifier = new WidgetIdentifier(widget.TypeIdentifier, widget.Identifier); + var migration = widgetMigrationService.GetWidgetMigration(context, identifier); + IReadOnlyDictionary propertyMigrations = new Dictionary(); + + if (migration is not null) + { + (var migratedValue, var propertyMigrationTypes, bool ndp) = await migration.MigrateWidget(identifier, JObject.FromObject(widget), context); + propertyMigrations = propertyMigrationTypes.ToDictionary(x => x.Key, x => widgetMigrationService.ResolveWidgetPropertyMigration(x.Value)); + needsDeferredPatch = ndp || needsDeferredPatch; + + widget.Variants.Clear(); + using var migratedValueReader = migratedValue.CreateReader(); + JsonSerializer.CreateDefault().Populate(migratedValueReader, widget); + } + foreach (var variant in widget.Variants) { logger.LogTrace("Migrating widget variant {Name}|{Identifier}", variant.Name, variant.Identifier); if (variant.Properties is { Count: > 0 } properties) { - foreach ((string key, var value) in properties) - { - logger.LogTrace("Migrating widget property {Name}|{Identifier}", key, value?.ToString()); - await MigrateProperties(siteId, properties, widgetCompos); - } + await MigrateProperties(siteId, properties, widgetCompos, propertyMigrations); } } } @@ -148,110 +165,119 @@ private async Task WalkWidgets(int siteId, List widge return needsDeferredPatch; } - private async Task MigrateProperties(int siteId, JObject properties, List? formControlModels) + private async Task MigrateProperties(int siteId, JObject properties, List? formControlModels, IReadOnlyDictionary explicitMigrations) { bool needsDeferredPatch = false; foreach ((string key, var value) in properties) { - logger.LogTrace("Walk property {Name}|{Identifier}", key, value?.ToString()); + logger.LogTrace("Migrating widget property {Name}|{Identifier}", key, value?.ToString()); var editingFcm = formControlModels?.FirstOrDefault(x => x.PropertyName.Equals(key, StringComparison.InvariantCultureIgnoreCase)); - if (editingFcm != null) + + IWidgetPropertyMigration? propertyMigration = null; + WidgetPropertyMigrationContext? context = null; + bool customMigrationApplied = false; + if (explicitMigrations.ContainsKey(key)) { - var context = new WidgetPropertyMigrationContext(siteId, editingFcm); - var widgetPropertyMigration = widgetMigrationService.GetWidgetPropertyMigrations(context, key); - bool allowDefaultMigrations = true; - bool customMigrationApplied = false; - if (widgetPropertyMigration != null) - { - (var migratedValue, bool ndp, allowDefaultMigrations) = await widgetPropertyMigration.MigrateWidgetProperty(key, value, context); - needsDeferredPatch = ndp || needsDeferredPatch; - properties[key] = migratedValue; - customMigrationApplied = true; - logger.LogTrace("Migration {Migration} applied to {Value}, resulting in {Result}", widgetPropertyMigration.GetType().FullName, value?.ToString() ?? "", migratedValue?.ToString() ?? ""); - } + context = new WidgetPropertyMigrationContext(siteId, null); + propertyMigration = explicitMigrations[key]; + } + else if (editingFcm is not null) + { + context = new WidgetPropertyMigrationContext(siteId, editingFcm); + propertyMigration = widgetMigrationService.GetWidgetPropertyMigration(context, key); + } + + bool allowDefaultMigrations = true; + if (propertyMigration is not null) + { + (var migratedValue, bool ndp, allowDefaultMigrations) = await propertyMigration.MigrateWidgetProperty(key, value, context!); + needsDeferredPatch = ndp || needsDeferredPatch; + properties[key] = migratedValue; + customMigrationApplied = true; + logger.LogTrace("Migration {Migration} applied to {Value}, resulting in {Result}", propertyMigration.GetType().FullName, value?.ToString() ?? "", migratedValue?.ToString() ?? ""); + } - if (allowDefaultMigrations) + if (allowDefaultMigrations && editingFcm is not null) + { + if (FieldMappingInstance.BuiltInModel.NotSupportedInKxpLegacyMode + .SingleOrDefault(x => x.OldFormComponent == editingFcm.FormComponentIdentifier) is var (oldFormComponent, newFormComponent)) { - if (FieldMappingInstance.BuiltInModel.NotSupportedInKxpLegacyMode - .SingleOrDefault(x => x.OldFormComponent == editingFcm.FormComponentIdentifier) is var (oldFormComponent, newFormComponent)) - { - logger.LogTrace("Editing form component found {FormComponentName} => no longer supported {Replacement}", editingFcm.FormComponentIdentifier, newFormComponent); + logger.LogTrace("Editing form component found {FormComponentName} => no longer supported {Replacement}", editingFcm.FormComponentIdentifier, newFormComponent); - switch (oldFormComponent) + switch (oldFormComponent) + { + case Kx13FormComponents.Kentico_AttachmentSelector when newFormComponent == FormComponents.AdminAssetSelectorComponent: { - case Kx13FormComponents.Kentico_AttachmentSelector when newFormComponent == FormComponents.AdminAssetSelectorComponent: + if (value?.ToObject>() is { Count: > 0 } items) { - if (value?.ToObject>() is { Count: > 0 } items) + var nv = new List(); + foreach (var asi in items) { - var nv = new List(); - foreach (var asi in items) + var attachment = modelFacade.SelectWhere("AttachmentSiteID = @attachmentSiteId AND AttachmentGUID = @attachmentGUID", + new SqlParameter("attachmentSiteID", siteId), + new SqlParameter("attachmentGUID", asi.FileGuid) + ) + .FirstOrDefault(); + if (attachment != null) { - var attachment = modelFacade.SelectWhere("AttachmentSiteID = @attachmentSiteId AND AttachmentGUID = @attachmentGUID", - new SqlParameter("attachmentSiteID", siteId), - new SqlParameter("attachmentGUID", asi.FileGuid) - ) - .FirstOrDefault(); - if (attachment != null) + switch (attachmentMigrator.MigrateAttachment(attachment).GetAwaiter().GetResult()) { - switch (attachmentMigrator.MigrateAttachment(attachment).GetAwaiter().GetResult()) + case MigrateAttachmentResultMediaFile { Success: true, MediaFileInfo: { } x }: { - case MigrateAttachmentResultMediaFile { Success: true, MediaFileInfo: { } x }: - { - nv.Add(new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize }); - break; - } - case MigrateAttachmentResultContentItem { Success: true, ContentItemGuid: { } contentItemGuid }: - { - nv.Add(new ContentItemReference { Identifier = contentItemGuid }); - break; - } - default: - { - logger.LogWarning("Attachment '{AttachmentGUID}' failed to migrate", asi.FileGuid); - break; - } + nv.Add(new AssetRelatedItem { Identifier = x.FileGUID, Dimensions = new AssetDimensions { Height = x.FileImageHeight, Width = x.FileImageWidth }, Name = x.FileName, Size = x.FileSize }); + break; + } + case MigrateAttachmentResultContentItem { Success: true, ContentItemGuid: { } contentItemGuid }: + { + nv.Add(new ContentItemReference { Identifier = contentItemGuid }); + break; + } + default: + { + logger.LogWarning("Attachment '{AttachmentGUID}' failed to migrate", asi.FileGuid); + break; } - } - else - { - logger.LogWarning("Attachment '{AttachmentGUID}' not found", asi.FileGuid); } } - - properties[key] = JToken.FromObject(nv); + else + { + logger.LogWarning("Attachment '{AttachmentGUID}' not found", asi.FileGuid); + } } - logger.LogTrace("Value migrated from {Old} model to {New} model", oldFormComponent, newFormComponent); - break; + properties[key] = JToken.FromObject(nv); } - default: - break; + logger.LogTrace("Value migrated from {Old} model to {New} model", oldFormComponent, newFormComponent); + break; } + + default: + break; } - else if (!customMigrationApplied) + } + else if (!customMigrationApplied) + { + if (FieldMappingInstance.BuiltInModel.SupportedInKxpLegacyMode.Contains(editingFcm.FormComponentIdentifier)) { - if (FieldMappingInstance.BuiltInModel.SupportedInKxpLegacyMode.Contains(editingFcm.FormComponentIdentifier)) - { - // OK - logger.LogTrace("Editing form component found {FormComponentName} => supported in legacy mode", editingFcm.FormComponentIdentifier); - } - else - { - // unknown control, probably custom - logger.LogTrace("Editing form component found {FormComponentName} => custom or inlined component, don't forget to migrate code accordingly", editingFcm.FormComponentIdentifier); - } + // OK + logger.LogTrace("Editing form component found {FormComponentName} => supported in legacy mode", editingFcm.FormComponentIdentifier); } - - - if ("NodeAliasPath".Equals(key, StringComparison.InvariantCultureIgnoreCase)) + else { - needsDeferredPatch = true; - properties["TreePath"] = value; - properties.Remove(key); + // unknown control, probably custom + logger.LogTrace("Editing form component found {FormComponentName} => custom or inlined component, don't forget to migrate code accordingly", editingFcm.FormComponentIdentifier); } } + + + if ("NodeAliasPath".Equals(key, StringComparison.InvariantCultureIgnoreCase)) + { + needsDeferredPatch = true; + properties["TreePath"] = value; + properties.Remove(key); + } } } diff --git a/Migration.Tool.CLI/Migration.Tool.CLI.csproj.user b/Migration.Tool.CLI/Migration.Tool.CLI.csproj.user new file mode 100644 index 00000000..854e66c6 --- /dev/null +++ b/Migration.Tool.CLI/Migration.Tool.CLI.csproj.user @@ -0,0 +1,9 @@ + + + + ProjectDebugger + + + Migration + + \ No newline at end of file diff --git a/Migration.Tool.CLI/appsettings.json b/Migration.Tool.CLI/appsettings.json index 24fbbacf..a7f377ef 100644 --- a/Migration.Tool.CLI/appsettings.json +++ b/Migration.Tool.CLI/appsettings.json @@ -19,7 +19,7 @@ "MigrationProtocolPath": "C:\\Logs\\protocol.txt", "KxConnectionString": "[TODO]", "KxCmsDirPath": "[TODO]", - "XbKDirPath": "[TODO]", + "XbKDirPath": "[TODO]", "XbKApiSettings": { "ConnectionStrings": { "CMSConnectionString": "[TODO]" diff --git a/Migration.Tool.Common/ConfigurationNames.cs b/Migration.Tool.Common/ConfigurationNames.cs index b1d10b0e..11e67997 100644 --- a/Migration.Tool.Common/ConfigurationNames.cs +++ b/Migration.Tool.Common/ConfigurationNames.cs @@ -22,6 +22,7 @@ public class ConfigurationNames public const string UseDeprecatedFolderPageType = "UseDeprecatedFolderPageType"; public const string ExcludeCodeNames = "ExcludeCodeNames"; + public const string ConvertClassesToContentHub = "ConvertClassesToContentHub"; public const string ExplicitPrimaryKeyMapping = "ExplicitPrimaryKeyMapping"; public const string SiteName = "SiteName"; diff --git a/Migration.Tool.Common/MigrationProtocol/HandbookReference.cs b/Migration.Tool.Common/MigrationProtocol/HandbookReference.cs index ae30b7d9..0d2888ce 100644 --- a/Migration.Tool.Common/MigrationProtocol/HandbookReference.cs +++ b/Migration.Tool.Common/MigrationProtocol/HandbookReference.cs @@ -26,6 +26,7 @@ public HandbookReference(string referenceName, string? additionalInfo = null) public string ReferenceName { get; } public string? AdditionalInfo { get; } public Dictionary? Data { get; private set; } + public string Suggestion { get; private set; } public override string ToString() { @@ -47,10 +48,19 @@ public override string ToString() sb.Append(", "); } } - + sb.AppendLine(); + if (!string.IsNullOrEmpty(Suggestion)) + { + sb.AppendLine($"Suggestion: {Suggestion}"); + } return sb.ToString(); } + public HandbookReference WithSuggestion(string suggestion) + { + Suggestion = suggestion; + return this; + } /// /// Related ID of data, specify if possible /// diff --git a/Migration.Tool.Common/Model/EditableAreasConfiguration.cs b/Migration.Tool.Common/Model/EditableAreasConfiguration.cs index b00f281e..11a0304f 100644 --- a/Migration.Tool.Common/Model/EditableAreasConfiguration.cs +++ b/Migration.Tool.Common/Model/EditableAreasConfiguration.cs @@ -89,7 +89,7 @@ public sealed class SectionConfiguration [DataMember] [JsonProperty("properties")] // public ISectionProperties Properties { get; set; } - public JObject Properties { get; set; } + public JObject? Properties { get; set; } /// Zones within the section. [DataMember] diff --git a/Migration.Tool.Common/ToolConfiguration.cs b/Migration.Tool.Common/ToolConfiguration.cs index bf01b952..15249bb6 100644 --- a/Migration.Tool.Common/ToolConfiguration.cs +++ b/Migration.Tool.Common/ToolConfiguration.cs @@ -49,12 +49,19 @@ public class ToolConfiguration [ConfigurationKeyName(ConfigurationNames.CreateReusableFieldSchemaForClasses)] public string? CreateReusableFieldSchemaForClasses { get; set; } + [ConfigurationKeyName(ConfigurationNames.ConvertClassesToContentHub)] + public string? ConvertClassesToContentHub { get; set; } public IReadOnlySet ClassNamesCreateReusableSchema => classNamesCreateReusableSchema ??= new HashSet( (CreateReusableFieldSchemaForClasses?.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? []).Select(x => x.Trim()), StringComparer.InvariantCultureIgnoreCase ); + public IReadOnlySet ClassNamesConvertToContentHub => classNamesConvertToContentHub ??= new HashSet( + (ConvertClassesToContentHub?.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? []).Select(x => x.Trim()), + StringComparer.InvariantCultureIgnoreCase + ); + #region Opt-in features [ConfigurationKeyName(ConfigurationNames.OptInFeatures)] @@ -96,6 +103,7 @@ public void SetXbKConnectionStringIfNotEmpty(string? connectionString) #region Path to root directory of target instance + private HashSet? classNamesConvertToContentHub; private HashSet? classNamesCreateReusableSchema; private string? xbKConnectionString; diff --git a/Migration.Tool.Core.K11/Handlers/MigrateUsersCommandHandler.cs b/Migration.Tool.Core.K11/Handlers/MigrateUsersCommandHandler.cs index 378a4501..5eb1f71d 100644 --- a/Migration.Tool.Core.K11/Handlers/MigrateUsersCommandHandler.cs +++ b/Migration.Tool.Core.K11/Handlers/MigrateUsersCommandHandler.cs @@ -89,6 +89,10 @@ private bool SaveUserUsingKenticoApi(IModelMappingResult mapped, CmsUs try { + if (string.IsNullOrEmpty(userInfo.Email)) + { + logger.LogError($"User {userInfo.UserName} does not have an email set. Email is required. You can set it via admin web interface of your source instance or directly in CMS_User database table."); + } UserInfoProvider.ProviderObject.Set(userInfo); protocol.Success(k11User, userInfo, mapped); diff --git a/Migration.Tool.Core.KX12/Handlers/MigrateUsersCommandHandler.cs b/Migration.Tool.Core.KX12/Handlers/MigrateUsersCommandHandler.cs index 42c482be..95989d6b 100644 --- a/Migration.Tool.Core.KX12/Handlers/MigrateUsersCommandHandler.cs +++ b/Migration.Tool.Core.KX12/Handlers/MigrateUsersCommandHandler.cs @@ -89,6 +89,10 @@ private void SaveUserUsingKenticoApi(IModelMappingResult mapped, KX12M try { + if (string.IsNullOrEmpty(userInfo.Email)) + { + logger.LogError($"User {userInfo.UserName} does not have an email set. Email is required. You can set it via admin web interface of your source instance or directly in CMS_User database table."); + } UserInfoProvider.ProviderObject.Set(userInfo); protocol.Success(k12User, userInfo, mapped); diff --git a/Migration.Tool.Core.KX13/Handlers/MigrateSitesCommandHandler.cs b/Migration.Tool.Core.KX13/Handlers/MigrateSitesCommandHandler.cs index 323bedd1..c18ddbd4 100644 --- a/Migration.Tool.Core.KX13/Handlers/MigrateSitesCommandHandler.cs +++ b/Migration.Tool.Core.KX13/Handlers/MigrateSitesCommandHandler.cs @@ -68,7 +68,7 @@ public async Task Handle(MigrateSitesCommand request, Cancellatio ContentLanguageGUID = cmsCulture.CultureGuid, ContentLanguageDisplayName = cmsCulture.CultureName, ContentLanguageName = cmsCulture.CultureCode, - ContentLanguageIsDefault = true, + ContentLanguageIsDefault = string.Equals(cmsCulture.CultureCode, defaultCultureCode, StringComparison.InvariantCultureIgnoreCase), ContentLanguageFallbackContentLanguageGuid = null, ContentLanguageCultureFormat = cmsCulture.CultureCode }); @@ -101,7 +101,7 @@ public async Task Handle(MigrateSitesCommand request, Cancellatio { WebsiteChannelGUID = kx13CmsSite.SiteGuid, WebsiteChannelChannelGuid = kx13CmsSite.SiteGuid, - WebsiteChannelDomain = kx13CmsSite.SiteDomainName, + WebsiteChannelDomain = kx13CmsSite.SiteDomainName.Trim('/'), WebsiteChannelHomePage = homePagePath, WebsiteChannelPrimaryContentLanguageGuid = migratedCultureCodes[defaultCultureCode].ContentLanguageGUID, WebsiteChannelDefaultCookieLevel = cookieLevel, diff --git a/Migration.Tool.Core.KX13/Handlers/MigrateUsersCommandHandler.cs b/Migration.Tool.Core.KX13/Handlers/MigrateUsersCommandHandler.cs index f13f1d09..0c90eae6 100644 --- a/Migration.Tool.Core.KX13/Handlers/MigrateUsersCommandHandler.cs +++ b/Migration.Tool.Core.KX13/Handlers/MigrateUsersCommandHandler.cs @@ -89,6 +89,10 @@ private Task SaveUserUsingKenticoApi(IModelMappingResult mapped, KX13M try { + if (string.IsNullOrEmpty(userInfo.Email)) + { + logger.LogError($"User {userInfo.UserName} does not have an email set. Email is required. You can set it via admin web interface of your source instance or directly in CMS_User database table."); + } UserInfoProvider.ProviderObject.Set(userInfo); protocol.Success(kx13User, userInfo, mapped); diff --git a/Migration.Tool.Extensions/ClassMappings/ClassMappingSample.cs b/Migration.Tool.Extensions/ClassMappings/ClassMappingSample.cs index 19e6c03a..fa0c0026 100644 --- a/Migration.Tool.Extensions/ClassMappings/ClassMappingSample.cs +++ b/Migration.Tool.Extensions/ClassMappings/ClassMappingSample.cs @@ -10,6 +10,67 @@ namespace Migration.Tool.Extensions.ClassMappings; public static class ClassMappingSample { + public static IServiceCollection AddReusableRemodelingSample(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.REUSABLE; + target.ClassWebPageHasUrl = false; + }); + + // 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")); + + // field clone sample + m + .BuildField("FarmRM_Clone") + .SetFrom(sourceClassName, "CoffeeFarm", true) + .WithFieldPatch(f => f.SetPropertyValue(FormFieldPropertyEnum.FieldCaption, "Farm RM Clone")); + + 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 AddSimpleRemodelingSample(this IServiceCollection serviceCollection) { const string targetClassName = "DancingGoatCore.CoffeeRemodeled"; diff --git a/Migration.Tool.Extensions/CommunityMigrations/SampleWidgetMigration.cs b/Migration.Tool.Extensions/CommunityMigrations/SampleWidgetMigration.cs new file mode 100644 index 00000000..d9a30a2c --- /dev/null +++ b/Migration.Tool.Extensions/CommunityMigrations/SampleWidgetMigration.cs @@ -0,0 +1,34 @@ +using Migration.Tool.Extensions.DefaultMigrations; +using Migration.Tool.KXP.Api.Services.CmsClass; +using Newtonsoft.Json.Linq; + +namespace Migration.Tool.Extensions.CommunityMigrations; +public class SampleWidgetMigration : IWidgetMigration +{ + public int Rank => 1; + + public async Task MigrateWidget(WidgetIdentifier identifier, JToken? value, WidgetMigrationContext context) + { + value!["type"] = "DancingGoat.HeroWidget"; //Migrate to different type of widget + + //Recombine the properties + var variants = (JArray)value!["variants"]!; + var singleVariant = variants[0]; + singleVariant["properties"] = new JObject + { + ["teaser"] = singleVariant["properties"]!["image"], + ["text"] = singleVariant["properties"]!["text"] + }; + + //For new properties, we must explicitly define property migration classes + var propertyMigrations = new Dictionary + { + ["teaser"] = typeof(WidgetFileMigration) + //["text"] ... this is an unchanged property from the original widget => default widget property migrations will handle it + }; + + return new WidgetMigrationResult(value, propertyMigrations); + } + + public bool ShallMigrate(WidgetMigrationContext context, WidgetIdentifier identifier) => string.Equals("DancingGoat.HomePage.BannerWidget", identifier.TypeIdentifier, StringComparison.InvariantCultureIgnoreCase); +} diff --git a/Migration.Tool.Extensions/DefaultMigrations/WidgetNoOpMigration.cs b/Migration.Tool.Extensions/DefaultMigrations/WidgetNoOpMigration.cs new file mode 100644 index 00000000..2a039d3c --- /dev/null +++ b/Migration.Tool.Extensions/DefaultMigrations/WidgetNoOpMigration.cs @@ -0,0 +1,13 @@ +using Migration.Tool.KXP.Api.Services.CmsClass; +using Newtonsoft.Json.Linq; + +namespace Migration.Tool.Extensions.DefaultMigrations; + +public class WidgetNoOpMigration : IWidgetPropertyMigration +{ + public int Rank => 1_000_000; + + public bool ShallMigrate(WidgetPropertyMigrationContext context, string propertyName) => false; // used only when explicitly stated in custom widget migration, ShallMigrate isn't used + + public Task MigrateWidgetProperty(string key, JToken? value, WidgetPropertyMigrationContext context) => Task.FromResult(new WidgetPropertyMigrationResult(value)); +} diff --git a/Migration.Tool.Extensions/README.md b/Migration.Tool.Extensions/README.md index 0167a2a4..cd1e2f62 100644 --- a/Migration.Tool.Extensions/README.md +++ b/Migration.Tool.Extensions/README.md @@ -7,13 +7,13 @@ Samples: To create custom migration: -- create new file in `Migration.Tool.Extensions/CommunityMigrations` (directory if you need more files for single migration) -- implement interface `Migration.Tool.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()` +- Create new file in `Migration.Tool.Extensions/CommunityMigrations` (directory if you need more files for single migration) +- Implement interface `Migration.Tool.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` +- Register in `Migration.Tool.Extensions/ServiceCollectionExtensions.cs` as `Transient` dependency into service collection. For example `services.AddTransient()` ## Custom class mappings for page types @@ -90,18 +90,48 @@ Finally, let's define relations to fields: ### Inject and use reusable schema -Example code is found in the method `AddReusableSchemaIntegrationSample` +Example code is found in the method `AddReusableSchemaIntegrationSample`. The goal of this method is to take a **single data class** and assign reusable schema. +### Convert page type to reusable content item (content hub) + +Example code is found in the method `AddReusableRemodelingSample`. + +Please note, that all information unique to page will be lost. + +## Custom widget migrations + +Custom widget migration allows you to remodel the original widget as a new widget type. The prominent operations are +changing the target widget type and recombining the original properties. + +To create custom widget migration: + +- create new file in `Migration.Tool.Extensions/CommunityMigrations` (directory if you need more files for single migration) +- implement interface `Migration.Tool.KXP.Api.Services.CmsClass.IWidgetMigration` + - implement property `Rank`, set number bellow 100 000 - for example 5000. Rank determines the order by which the migrations are tested to be eligible via the `ShallMigrate` method + - implement method `ShallMigrate`. If method returns true, migration will be used. This method receives a context, by which you can decide - typically by the original widget's type + - implement `MigrateWidget`, where objective is to convert old JToken representing the widget's JSON to new converted JToken value + - Widget property migration will still be applied after your custom widget migration + - In the following cases, you must explicitly specify the property migration to be used, via `PropertyMigrations` in returned value (because it can't be infered from the original widget) + - If you add a new property. That includes renaming an original property. + - In the special case when you introduce a new property whose name overlaps with original property. Otherwise the migration infered from the original property would be used + - If your new property is not supposed to be subject to property migrations and the original one was, explicitly specify `WidgetNoOpMigration` for this property + - You can also override the property migration of an original property if that suits your case +- Register in `Migration.Tool.Extensions/ServiceCollectionExtensions.cs` as `Transient` dependency into service collection. For example `services.AddTransient()` + +Samples: + +- [Sample BannerWidget migration](./CommunityMigrations/SampleWidgetMigration.cs) + ## Custom widget property migrations To create custom widget property migration: - Create new file in `Migration.Tool.Extensions/CommunityMigrations` (directory if you need more files for single migration) - Implement interface `Migration.Tool.KXP.Api.Services.CmsClass.IWidgetPropertyMigration` - - Implement property rank, set number bellow 100 000 - for example 5000 - - Implement method shall migrate (if method returns true, migration will be used) + - Implement property `Rank`, set number bellow 100 000 - for example 5000. Rank determines the order by which the migrations are tested to be eligible via the `ShallMigrate` method + - Implement method `ShallMigrate` (if method returns true, migration will be used) - Implement `MigrateWidgetProperty`, where objective is to convert old JToken representing json value to new converted JToken value - Register in `Migration.Tool.Extensions/ServiceCollectionExtensions.cs` as `Transient` dependency into service collection. For example `services.AddTransient()` diff --git a/Migration.Tool.Extensions/ServiceCollectionExtensions.cs b/Migration.Tool.Extensions/ServiceCollectionExtensions.cs index 61c751d3..569d89d6 100644 --- a/Migration.Tool.Extensions/ServiceCollectionExtensions.cs +++ b/Migration.Tool.Extensions/ServiceCollectionExtensions.cs @@ -16,8 +16,10 @@ public static IServiceCollection UseCustomizations(this IServiceCollection servi services.AddTransient(); services.AddTransient(); + // services.AddClassMergeExample(); // services.AddSimpleRemodelingSample(); + // services.AddReusableRemodelingSample(); // services.AddReusableSchemaIntegrationSample(); return services; } diff --git a/Migration.Tool.KXP.Api/Services/CmsClass/ICustomMigration.cs b/Migration.Tool.KXP.Api/Services/CmsClass/ICustomMigration.cs new file mode 100644 index 00000000..e1a073aa --- /dev/null +++ b/Migration.Tool.KXP.Api/Services/CmsClass/ICustomMigration.cs @@ -0,0 +1,9 @@ +namespace Migration.Tool.KXP.Api.Services.CmsClass; + +public interface ICustomMigration +{ + /// + /// 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; } +} diff --git a/Migration.Tool.KXP.Api/Services/CmsClass/IWidgetMigration.cs b/Migration.Tool.KXP.Api/Services/CmsClass/IWidgetMigration.cs index b0cdcf3a..15421d28 100644 --- a/Migration.Tool.KXP.Api/Services/CmsClass/IWidgetMigration.cs +++ b/Migration.Tool.KXP.Api/Services/CmsClass/IWidgetMigration.cs @@ -1,18 +1,13 @@ -using Migration.Tool.Common.Services.Ipc; -using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Linq; namespace Migration.Tool.KXP.Api.Services.CmsClass; -public record WidgetPropertyMigrationContext(int SiteId, EditingFormControlModel? EditingFormControlModel); -public record WidgetPropertyMigrationResult(JToken? Value, bool NeedsDeferredPatch = false, bool AllowDefaultMigrations = true); +public record WidgetIdentifier(string TypeIdentifier, Guid InstanceIdentifier); +public record WidgetMigrationContext(int SiteId); +public record WidgetMigrationResult(JToken? Value, IReadOnlyDictionary PropertyMigrations, bool NeedsDeferredPatch = false); -public interface IWidgetPropertyMigration +public interface IWidgetMigration : ICustomMigration { - /// - /// 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; } - - bool ShallMigrate(WidgetPropertyMigrationContext context, string propertyName); - Task MigrateWidgetProperty(string key, JToken? value, WidgetPropertyMigrationContext context); + bool ShallMigrate(WidgetMigrationContext context, WidgetIdentifier identifier); + Task MigrateWidget(WidgetIdentifier identifier, JToken? value, WidgetMigrationContext context); } diff --git a/Migration.Tool.KXP.Api/Services/CmsClass/IWidgetPropertyMigration.cs b/Migration.Tool.KXP.Api/Services/CmsClass/IWidgetPropertyMigration.cs new file mode 100644 index 00000000..590277d0 --- /dev/null +++ b/Migration.Tool.KXP.Api/Services/CmsClass/IWidgetPropertyMigration.cs @@ -0,0 +1,13 @@ +using Migration.Tool.Common.Services.Ipc; +using Newtonsoft.Json.Linq; + +namespace Migration.Tool.KXP.Api.Services.CmsClass; + +public record WidgetPropertyMigrationContext(int SiteId, EditingFormControlModel? EditingFormControlModel); +public record WidgetPropertyMigrationResult(JToken? Value, bool NeedsDeferredPatch = false, bool AllowDefaultMigrations = true); + +public interface IWidgetPropertyMigration : ICustomMigration +{ + bool ShallMigrate(WidgetPropertyMigrationContext context, string propertyName); + Task MigrateWidgetProperty(string key, JToken? value, WidgetPropertyMigrationContext context); +} diff --git a/Migration.Tool.KXP.Api/Services/CmsClass/WidgetMigrationService.cs b/Migration.Tool.KXP.Api/Services/CmsClass/WidgetMigrationService.cs index e07c6c6d..1a30ef4f 100644 --- a/Migration.Tool.KXP.Api/Services/CmsClass/WidgetMigrationService.cs +++ b/Migration.Tool.KXP.Api/Services/CmsClass/WidgetMigrationService.cs @@ -5,15 +5,28 @@ namespace Migration.Tool.KXP.Api.Services.CmsClass; public class WidgetMigrationService { private readonly List widgetPropertyMigrations; + private readonly List widgetMigrations; public WidgetMigrationService(IServiceProvider serviceProvider) { - var migrations = serviceProvider.GetService>(); - widgetPropertyMigrations = migrations == null + widgetPropertyMigrations = LoadRegisteredMigrations(serviceProvider); + widgetMigrations = LoadRegisteredMigrations(serviceProvider); + } + + private List LoadRegisteredMigrations(IServiceProvider serviceProvider) where T : ICustomMigration + { + var registeredMigrations = serviceProvider.GetService>(); + return registeredMigrations == null ? [] - : migrations.OrderBy(wpm => wpm.Rank).ToList(); + : registeredMigrations.OrderBy(wpm => wpm.Rank).ToList(); } - public IWidgetPropertyMigration? GetWidgetPropertyMigrations(WidgetPropertyMigrationContext context, string key) + public IWidgetPropertyMigration? GetWidgetPropertyMigration(WidgetPropertyMigrationContext context, string key) => widgetPropertyMigrations.FirstOrDefault(wpm => wpm.ShallMigrate(context, key)); + + public IWidgetPropertyMigration ResolveWidgetPropertyMigration(Type type) + => widgetPropertyMigrations.FirstOrDefault(x => x.GetType() == type) ?? throw new ArgumentException($"No migration of type {type} registered", nameof(type)); + + public IWidgetMigration? GetWidgetMigration(WidgetMigrationContext context, WidgetIdentifier identifier) + => widgetMigrations.FirstOrDefault(wpm => wpm.ShallMigrate(context, identifier)); } diff --git a/README.md b/README.md index 1f63f256..63ca1343 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## Description -This repository is part of the [Xperience by Kentico Migration Tool](https://github.com/Kentico/xperience-by-kentico-migration-tool). +This repository is part of the [Xperience by Kentico Migration Toolkit](https://github.com/Kentico/xperience-by-kentico-migration-toolkit). The Kentico Migration Tool transfers content and other data from **Kentico Xperience 13**, **Kentico 12** or **Kentico 11** to **Xperience by Kentico**.