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/Services/AssetFacade.cs b/KVA/Migration.Tool.Source/Services/AssetFacade.cs index eb36e9c1..b0a1d4ff 100644 --- a/KVA/Migration.Tool.Source/Services/AssetFacade.cs +++ b/KVA/Migration.Tool.Source/Services/AssetFacade.cs @@ -111,7 +111,7 @@ public async Task FromMediaFile(IMediaFile mediaFile languageData.AddRange(contentLanguageNames.Select(contentLanguageName => new ContentItemLanguageData { LanguageName = contentLanguageName, - DisplayName = $"{mediaFile.FileName}", + DisplayName = mediaFile.FileName, UserGuid = createdByUser?.UserGUID, VersionStatus = VersionStatus.Published, ContentItemData = new Dictionary @@ -164,7 +164,7 @@ public async Task FromAttachment(ICmsAttachment atta var contentLanguageData = new ContentItemLanguageData { LanguageName = contentLanguageName, - DisplayName = $"{attachment.AttachmentName}", + DisplayName = attachment.AttachmentName, UserGuid = null, VersionStatus = VersionStatus.Published, ContentItemData = new Dictionary diff --git a/KVA/Migration.Tool.Source/Services/PageBuilderPatcher.cs b/KVA/Migration.Tool.Source/Services/PageBuilderPatcher.cs index 628da52e..5f918e91 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,7 +92,7 @@ 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); + bool ndp1 = await MigrateProperties(siteId, section.Properties, sectionFcs, new Dictionary()); needsDeferredPatch = ndp1 || needsDeferredPatch; if (section.Zones is { Count: > 0 }) @@ -128,19 +128,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,109 +161,118 @@ 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); + } - if (allowDefaultMigrations) + 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 && 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) + { + if (FieldMappingInstance.BuiltInModel.SupportedInKxpLegacyMode.Contains(editingFcm.FormComponentIdentifier)) + { + // OK + logger.LogTrace("Editing form component found {FormComponentName} => supported in legacy mode", editingFcm.FormComponentIdentifier); } - else if (!customMigrationApplied) + else { - 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); - } + // 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); - } + if ("NodeAliasPath".Equals(key, StringComparison.InvariantCultureIgnoreCase)) + { + needsDeferredPatch = true; + properties["TreePath"] = value; + properties.Remove(key); } } } 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 a71cbf6a..c6a4b3c9 100644 --- a/Migration.Tool.Extensions/README.md +++ b/Migration.Tool.Extensions/README.md @@ -80,14 +80,36 @@ serviceCollection.AddSingleton(m); demonstrated in method `AddReusableSchemaIntegrationSample`, goal is to take single data class and assign reusable schema. +## 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 + +- finally 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 - finally register in `Migration.Tool.Extensions/ServiceCollectionExtensions.cs` as `Transient` dependency into service collection. For example `services.AddTransient()` 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)); }