Skip to content

Commit 59f99ed

Browse files
authored
Merge pull request #550 from Kentico/develop
Release 3.21.0
2 parents 66f8a67 + a97367e commit 59f99ed

File tree

9 files changed

+151
-34
lines changed

9 files changed

+151
-34
lines changed

KVA/Migration.Tool.Source/Handlers/MigratePagesCommandHandler.cs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,6 @@ private async Task MigratePages()
335335
? (Guid?)null
336336
: spoiledGuidContext.EnsureNodeGuid(ksNodeParent);
337337

338-
DataClassInfo targetClass = null!;
339338
var classMapping = classMappingProvider.GetMapping(ksNodeClass.ClassName);
340339

341340
var producedReusableSchemas = reusableSchemaBuilders.Where(x =>
@@ -348,17 +347,12 @@ private async Task MigratePages()
348347
continue;
349348
}
350349

351-
targetClass = classMapping != null
352-
? DataClassInfoProvider.ProviderObject.Get(classMapping.TargetClassName)
353-
: DataClassInfoProvider.ProviderObject.Get(ksNodeClass.ClassGUID);
354-
355350
var results = mapper.Map(new CmsTreeMapperSource(
356351
ksNode,
357352
safeNodeName,
358353
ksSite.SiteGUID,
359354
nodeParentGuid,
360355
cultureCodeToLanguageGuid!,
361-
targetClass?.ClassFormDefinition,
362356
ksNodeClass.ClassFormDefinition,
363357
migratedDocuments,
364358
ksSite,
@@ -424,11 +418,13 @@ private async Task MigratePages()
424418
}
425419
}
426420

421+
var targetClassInfo = contentItemDirective!.TargetClassInfo;
422+
427423
if (contentItemDirective is not DropDirective)
428424
{
429425
AssertVersionStatusRule(commonDataInfos);
430426

431-
if (webPageItemInfo != null && targetClass is { ClassWebPageHasUrl: true })
427+
if (webPageItemInfo != null && targetClassInfo is { ClassWebPageHasUrl: true })
432428
{
433429
await GenerateDefaultPageUrlPath(ksNode, webPageItemInfo);
434430
if (!contentItemDirective!.RegenerateUrlPath)

KVA/Migration.Tool.Source/Mappers/ContentItemMapper.cs

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ public record CmsTreeMapperSource(
3939
Guid SiteGuid,
4040
Guid? NodeParentGuid,
4141
Dictionary<string, Guid> CultureToLanguageGuid,
42-
string? TargetFormDefinition,
4342
string SourceFormDefinition,
4443
List<ICmsDocument> MigratedDocuments,
4544
ICmsSite SourceSite,
@@ -91,51 +90,46 @@ WorkspaceService workspaceService
9190

9291
protected override IEnumerable<IUmtModel> MapInternal(CmsTreeMapperSource source)
9392
{
94-
(var cmsTree, string safeNodeName, var siteGuid, var nodeParentGuid, var cultureToLanguageGuid, string? targetFormDefinition, string sourceFormDefinition, var migratedDocuments, var sourceSite, bool deferred) = source;
93+
(var cmsTree, string safeNodeName, var siteGuid, var nodeParentGuid, var cultureToLanguageGuid, string sourceFormDefinition, var migratedDocuments, var sourceSite, bool deferred) = source;
9594
logger.LogTrace("Mapping {Value}", new { cmsTree.NodeAliasPath, cmsTree.NodeName, cmsTree.NodeGUID, cmsTree.NodeSiteID });
9695

9796
var childNodes = modelFacade.Select<ICmsTree>("NodeParentID = @nodeID", "NodeOrder", new SqlParameter("nodeID", cmsTree.NodeID))!.ToArray();
9897

9998
var sourceNodeClass = modelFacade.SelectById<ICmsClass>(cmsTree.NodeClassID) ?? throw new InvalidOperationException($"Fatal: node class is missing, class id '{cmsTree.NodeClassID}'");
10099
var mapping = classMappingProvider.GetMapping(sourceNodeClass.ClassName);
101-
var targetClassGuid = sourceNodeClass.ClassGUID;
102-
var targetClassInfo = DataClassInfoProvider.ProviderObject.Get(sourceNodeClass.ClassName);
103-
if (mapping is not null)
100+
101+
var formerUrlPaths = GetFormerUrlPaths(cmsTree);
102+
var directive = GetDirective(new ContentItemSource(cmsTree, sourceNodeClass.ClassName, mapping?.TargetClassName ?? sourceNodeClass.ClassName, sourceSite, formerUrlPaths, childNodes));
103+
104+
if (directive is DropDirective)
104105
{
105-
targetClassInfo = DataClassInfoProvider.ProviderObject.Get(mapping.TargetClassName) ?? throw new InvalidOperationException($"Unable to find target class '{mapping.TargetClassName}'");
106-
targetClassGuid = targetClassInfo.ClassGUID;
106+
logger.LogInformation("Content item skipped. Reason: {Reason} NodeGUID: {NodeGUID} NodeAliasPath: {NodeAliasPath}", "Explicit drop directive", cmsTree.NodeGUID, cmsTree.NodeAliasPath);
107+
yield break;
107108
}
108109

110+
string targetClassName = directive.TargetTypeOverride ?? mapping?.TargetClassName ?? sourceNodeClass.ClassName;
111+
var targetClassInfo = DataClassInfoProvider.ProviderObject.Get(targetClassName);
109112
bool migratedAsContentFolder = sourceNodeClass.ClassName.Equals("cms.folder", StringComparison.InvariantCultureIgnoreCase) && !configuration.UseDeprecatedFolderPageType.GetValueOrDefault(false);
110113

111114
if (targetClassInfo is null && !migratedAsContentFolder)
112115
{
113-
logger.LogError("Could not map content item. Target class DataClassInfo ClassGUID={ClassGUID} not found.", targetClassGuid);
116+
logger.LogError("Could not map source node NodeGUID={NodeGuid}. Target class with name {ClassName} was not identified in target instance.", cmsTree.NodeGUID, targetClassName);
114117
yield break;
115118
}
116119

117120
var contentItemGuid = spoiledGuidContext.EnsureNodeGuid(cmsTree.NodeGUID, cmsTree.NodeSiteID, cmsTree.NodeID);
118121

119-
var formerUrlPaths = GetFormerUrlPaths(cmsTree);
120-
121-
var directive = GetDirective(new ContentItemSource(cmsTree, sourceNodeClass.ClassName, mapping?.TargetClassName ?? sourceNodeClass.ClassName, sourceSite, formerUrlPaths, childNodes));
122122
directive.ContentItemGuid = contentItemGuid;
123123
directive.TargetClassInfo = targetClassInfo;
124124
directive.Node = cmsTree;
125125
yield return directive;
126126

127-
if (directive is DropDirective)
128-
{
129-
logger.LogInformation("Content item skipped. Reason: {Explicit drop directive} NodeGUID: {NodeGUID} NodeAliasPath: {NodeAliasPath}", "Explicit drop directive", cmsTree.NodeGUID, cmsTree.NodeAliasPath);
130-
yield break;
131-
}
132-
else if (directive is ConvertToWidgetDirective && cmsTree.NodeHasChildren == true && !deferred)
127+
if (directive is ConvertToWidgetDirective && cmsTree.NodeHasChildren == true && !deferred)
133128
{
134129
deferredTreeNodesService.AddNode(cmsTree);
135130
yield break;
136131
}
137132

138-
139133
bool isMappedTypeReusable = targetClassInfo?.ClassContentTypeType is ClassContentTypeType.REUSABLE || configuration.ClassNamesConvertToContentHub.Contains(sourceNodeClass.ClassName);
140134
if (isMappedTypeReusable)
141135
{
@@ -153,7 +147,7 @@ protected override IEnumerable<IUmtModel> MapInternal(CmsTreeMapperSource source
153147
ContentItemName = safeNodeName,
154148
ContentItemIsReusable = isMappedTypeReusable,
155149
ContentItemIsSecured = cmsTree.IsSecuredNode ?? false,
156-
ContentItemDataClassGuid = migratedAsContentFolder ? null : targetClassGuid,
150+
ContentItemDataClassGuid = migratedAsContentFolder ? null : targetClassInfo!.ClassGUID,
157151
ContentItemChannelGuid = isMappedTypeReusable ? null : siteGuid,
158152
ContentItemContentFolderGUID = contentFolderGuid,
159153
ContentItemWorkspaceGUID = workspaceGuid
@@ -200,7 +194,7 @@ protected override IEnumerable<IUmtModel> MapInternal(CmsTreeMapperSource source
200194
List<IUmtModel>? migratedDraft = null;
201195
try
202196
{
203-
migratedDraft = MigrateDraft(draftVersion, cmsTree, sourceFormDefinition, targetFormDefinition!, contentItemGuid, languageGuid, sourceNodeClass, websiteChannelInfo, sourceSite, mapping).ToList();
197+
migratedDraft = MigrateDraft(draftVersion, cmsTree, sourceFormDefinition, targetClassInfo!.ClassFormDefinition, contentItemGuid, languageGuid, sourceNodeClass, websiteChannelInfo, sourceSite, mapping).ToList();
204198
draftMigrated = true;
205199
}
206200
catch
@@ -325,9 +319,9 @@ protected override IEnumerable<IUmtModel> MapInternal(CmsTreeMapperSource source
325319

326320
if (!migratedAsContentFolder)
327321
{
328-
var dataModel = new ContentItemDataModel { ContentItemDataGUID = commonDataModel.ContentItemCommonDataGUID, ContentItemDataCommonDataGuid = commonDataModel.ContentItemCommonDataGUID, ContentItemContentTypeName = mapping?.TargetClassName ?? sourceNodeClass.ClassName };
322+
var dataModel = new ContentItemDataModel { ContentItemDataGUID = commonDataModel.ContentItemCommonDataGUID, ContentItemDataCommonDataGuid = commonDataModel.ContentItemCommonDataGUID, ContentItemContentTypeName = targetClassInfo!.ClassName };
329323

330-
var fi = new FormInfo(targetFormDefinition);
324+
var fi = new FormInfo(targetClassInfo!.ClassFormDefinition);
331325

332326
var includedMetadata = configuration.IncludeExtendedMetadata.GetValueOrDefault(false) ? IncludedMetadata.Extended : IncludedMetadata.Basic;
333327
FormFieldInfo[] commonFields = UnpackReusableFieldSchemas(fi.GetFields<FormSchemaInfo>()).ToArray();
@@ -358,8 +352,6 @@ protected override IEnumerable<IUmtModel> MapInternal(CmsTreeMapperSource source
358352
}
359353
}
360354

361-
string targetClassName = mapping?.TargetClassName ?? sourceNodeClass.ClassName;
362-
363355
// Map legacy metadata fields
364356
// Fields from custom class mapping
365357
if (mapping is not null)
@@ -915,7 +907,7 @@ string targetClassName
915907
{
916908
// relation to other document
917909
var convertedRelation = relationshipService.GetNodeRelationships(documentSourceObjectContext.CmsTree.NodeID, sourceNodeClass.ClassName, field.Guid)
918-
.Select(r => new WebPageRelatedItem { WebPageGuid = spoiledGuidContext.EnsureNodeGuid(r.RightNode!.NodeGUID, r.RightNode.NodeSiteID, r.RightNode.NodeID) });
910+
.Select(r => new { Identifier = spoiledGuidContext.EnsureNodeGuid(r.RightNode!.NodeGUID, r.RightNode.NodeSiteID, r.RightNode.NodeID) });
919911

920912
target.SetValueAsJson(targetFieldName, valueConvertor.Invoke(convertedRelation, convertorContext));
921913
}

KVA/Migration.Tool.Source/Mappers/ContentItemMapperDirectives/ContentItemActionProvider.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@ public void LinkChildren(string fieldName, IEnumerable<ICmsTree> children)
3535
Directive.ChildLinks.Add((fieldName, child));
3636
}
3737
}
38+
public void OverrideTargetType(string targetType) => Directive.TargetTypeOverride = targetType;
3839
}

KVA/Migration.Tool.Source/Mappers/ContentItemMapperDirectives/ContentItemDirectiveBase.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ internal abstract class ContentItemDirectiveBase : IUmtModel
1414
public bool RegenerateUrlPath { get; set; } = false;
1515
public IEnumerable<FormerPageUrlPath>? FormerUrlPaths { get; set; }
1616
public List<(string fieldName, ICmsTree)> ChildLinks { get; set; } = [];
17+
public string? TargetTypeOverride { get; set; }
1718

1819
#region Mapping results, used for postprocessing
1920
public Guid ContentItemGuid { get; set; }

KVA/Migration.Tool.Source/Mappers/ContentItemMapperDirectives/IContentItemActionProvider.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ public interface IContentItemActionProvider : IBaseContentItemActionProvider
2626
/// <param name="fieldName">Name of the field to add the child content item references to</param>
2727
/// <param name="children">One or more child objects passed by <see cref="ContentItemSource.ChildNodes"/></param>
2828
void LinkChildren(string fieldName, IEnumerable<ICmsTree> children);
29+
30+
/// <summary>
31+
/// Instructs Migration Tool to map the source page to a specific content type on XbyK side.
32+
/// </summary>
33+
/// <param name="targetTypeName">Class name of the target content type (ClassName of CMS_Class table)</param>
34+
void OverrideTargetType(string targetTypeName);
2935
}
3036

3137
public record FormerPageUrlPath(string LanguageName, string Path, DateTime? LastModified = null);

Migration.Tool.Common/Builders/ClassMapper.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ public record FieldMapping(string TargetFieldName, string SourceClassName, strin
4040

4141
public record FieldMappingWithConversion(string TargetFieldName, string SourceClassName, string SourceFieldName, bool IsTemplate, Func<object?, IConvertorContext, object?> Converter) : IFieldMapping;
4242

43-
public class MultiClassMapping(string targetClassName, Action<DataClassInfo> classPatcher) : IClassMapping
43+
public class MultiClassMapping(string targetClassName, Action<DataClassInfo> classPatcher = null) : IClassMapping
4444
{
45-
public void PatchTargetDataClass(DataClassInfo target) => classPatcher(target);
45+
public void PatchTargetDataClass(DataClassInfo target) => classPatcher?.Invoke(target);
4646

4747
ICollection<string> IClassMapping.SourceClassNames => SourceClassNames;
4848
IList<IFieldMapping> IClassMapping.Mappings => Mappings;

Migration.Tool.Extensions/ClassMappings/ClassMappingSample.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,4 +541,56 @@ public static IServiceCollection AddReusableSchemaAutoGenerationSample(this ISer
541541

542542
return serviceCollection;
543543
}
544+
545+
public static IServiceCollection AddMappingToPrefabricatedContentTypeSample(this IServiceCollection serviceCollection)
546+
{
547+
// Summary:
548+
// This sample shows how to migrate pages to a content type that is already present in the target instance
549+
// - i.e., not created by the --page-types command.
550+
//
551+
// Prerequisites:
552+
// 1. CLI is invoked with --bypass-dependency-check argument and without --page-types argument.
553+
//
554+
// 2. The following structures already exist in the target XbyK instance. They might have been created by MT,
555+
// manually in XbyK administration or by other means.
556+
// A. Reusable field schema PrefabBase
557+
// B. Content type DancingGoatCore.PrefabArticle that uses PrefabBase. It can be of both reusable and page usage.
558+
// C. In case of migrating media to content hub, Legacy Media File content type must exist
559+
// and match AssetFacade.LegacyMediaFileContentType class name and class GUID. See PrefabArticleTeaser below.
560+
//
561+
// 3. Field types in the target structures must be compatible with source field types - i.e., Migration Tool must support
562+
// migration from one to the other.
563+
//
564+
// If you're unsure what the target field type should be, let MT migrate page types (--page-types CLI command)
565+
// into a disposable clone of your target instance and see the produced field types.
566+
567+
var m = new MultiClassMapping("DancingGoatCore.PrefabArticle");
568+
const string sourceClassName = "DancingGoatCore.Article";
569+
570+
// Field mapping
571+
m.BuildField("PrefabArticleSummary")
572+
.SetFrom(sourceClassName, "ArticleSummary");
573+
574+
// For media file mapping, make sure the target field supports the media file migration strategy
575+
// set by appsettings MigrateMediaToMediaLibrary.
576+
// - MigrateMediaToMediaLibrary=true -> target field data type = 'Media files'
577+
// - MigrateMediaToMediaLibrary=false -> target field data type = 'Pages and reusable content'
578+
// and allowed content types must include Legacy Media File content type. See AssetFacade.LegacyMediaFileContentType
579+
m.BuildField("PrefabArticleTeaser")
580+
.SetFrom(sourceClassName, "ArticleTeaser");
581+
582+
// For linked items mapping, make sure the target field has 'Pages and reusable content' data type
583+
// and allowed content types include the content type of linked items. What this linked content type actually is
584+
// depends on your specific setup (custom class mapping, content item directors, etc.).
585+
m.BuildField("PrefabArticleRelatedArticles")
586+
.SetFrom(sourceClassName, "ArticleRelatedArticles");
587+
588+
// Reusable field schema field mapping
589+
m.UseResusableSchema("PrefabBase");
590+
m.BuildField("PrefabBaseTitle")
591+
.SetFrom(sourceClassName, "ArticleTitle");
592+
593+
serviceCollection.AddSingleton<IClassMapping>(m);
594+
return serviceCollection;
595+
}
544596
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using System.Text.Json;
2+
using Migration.Tool.Source.Mappers.ContentItemMapperDirectives;
3+
4+
namespace Migration.Tool.Extensions.CommunityMigrations;
5+
6+
/// <summary>
7+
/// This content item director overrides target content type based on class name of the source page.
8+
/// The override logic is driven by a JSON file. Unspecified source classes are not affected.
9+
/// </summary>
10+
/// <remarks>
11+
/// JSON file example:
12+
/// {
13+
/// "mapping": {
14+
/// "contentTypes": {
15+
/// "DancingGoatCore.Article": "NewProjectNamespace.PrefabricatedArticleType",
16+
/// "DancingGoatCore.Product": "NewProjectNamespace.PrefabricatedProductType"
17+
/// }
18+
/// }
19+
/// }
20+
/// </remarks>
21+
public class JsonBasedTypeRemapDirector : ContentItemDirectorBase
22+
{
23+
private readonly Dictionary<string, string> definitions;
24+
25+
public JsonBasedTypeRemapDirector(string jsonFilePath)
26+
{
27+
if (string.IsNullOrWhiteSpace(jsonFilePath))
28+
{
29+
throw new ArgumentException("JSON file path cannot be null or empty", nameof(jsonFilePath));
30+
}
31+
32+
var jsonContent = File.ReadAllText(jsonFilePath);
33+
var mappingData = JsonSerializer.Deserialize<MappingDefinitions>(jsonContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
34+
35+
definitions = mappingData?.Mapping?.ContentTypes ?? throw new InvalidOperationException("Invalid JSON structure. Expected 'mapping.contentTypes'.");
36+
}
37+
38+
public override void Direct(ContentItemSource source, IContentItemActionProvider options)
39+
{
40+
if (definitions.TryGetValue(source.SourceClassName, out var targetTypeName))
41+
{
42+
options.OverrideTargetType(targetTypeName);
43+
}
44+
}
45+
46+
private class MappingDefinitions
47+
{
48+
public MappingData? Mapping { get; set; }
49+
50+
public class MappingData
51+
{
52+
public Dictionary<string, string>? ContentTypes { get; set; }
53+
}
54+
}
55+
}

Migration.Tool.Extensions/ServiceCollectionExtensions.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,20 @@ public static IServiceCollection UseCustomizations(this IServiceCollection servi
2525
// services.AddReusableSchemaAutoGenerationSample();
2626
// services.AddTransient<ContentItemDirectorBase, SamplePageToWidgetDirector>();
2727
// services.AddTransient<ContentItemDirectorBase, SampleChildLinkDirector>();
28+
29+
// Routing content items to prefabricated content types (i.e., types not created by Migration Tool --page-types CLI argument)
30+
//
31+
// The following two methods may be combined, but one particular content type should be covered by only one of them.
32+
//
33+
// 1. Content item director method is applicable if each target field has a matching source field that has the same name.
34+
// You may use JsonBasedTypeRemapDirector or drive the mapping directly from your own director using IContentItemActionProvider.OverrideTargetType method.
35+
//
36+
// services.AddTransient<ContentItemDirectorBase>(sp => new JsonBasedTypeRemapDirector("migration-mapping.json"));
37+
//
38+
// 2. Custom mapping method gives you the highest flexibility if the prefabricated type doesn't match the source type exactly.
39+
//
40+
// services.AddMappingToPrefabricatedContentTypeSample();
41+
2842
return services;
2943
}
3044
}

0 commit comments

Comments
 (0)