Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions KVA/Migration.Tool.Source/Handlers/MigratePagesCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
using CMS.Core;
using CMS.Core.Internal;
using CMS.DataEngine;
using CMS.DataEngine.Internal;
using CMS.DataEngine.Query;
using CMS.FormEngine;
using CMS.Websites;
using CMS.Websites.Internal;
using CMS.Websites.Routing.Internal;
Expand All @@ -20,6 +22,8 @@
using Migration.Tool.Common.Helpers;
using Migration.Tool.Common.MigrationProtocol;
using Migration.Tool.Common.Model;
using Migration.Tool.KXP.Api.Auxiliary;
using Migration.Tool.KXP.Api.Services.CmsClass;
using Migration.Tool.Source.Contexts;
using Migration.Tool.Source.Helpers;
using Migration.Tool.Source.Mappers;
Expand Down Expand Up @@ -206,6 +210,10 @@ private async Task MigratePages()
new SqlParameter("siteId", ksSite.SiteID)
);

// Map from original node GUID to mapped result. Using original node GUID is possible only within context of one site
// as only then is the GUID guaranteed by legacy versions to be unique
Dictionary<Guid, NodeMapResult> mappedSiteNodes = [];

for (int pass = 0; pass < 2; pass++)
{
deferredTreeNodesService.Clear();
Expand Down Expand Up @@ -353,6 +361,7 @@ private async Task MigratePages()
if (umtModel is ContentItemDirectiveBase yieldedDirective)
{
contentItemDirective = yieldedDirective;
mappedSiteNodes[contentItemDirective!.Node!.NodeGUID] = new(contentItemDirective!.Node!, contentItemDirective.ContentItemGuid, [], contentItemDirective.TargetClassInfo!, contentItemDirective.ChildLinks);
}
else
{
Expand Down Expand Up @@ -387,6 +396,11 @@ private async Task MigratePages()
contentItemInfo = cii;
break;
}
case { Success: true, Imported: ContentItemDataInfo cidi }:
{
mappedSiteNodes[ksNode.NodeGUID].ContentItemDataGuids.Add(cidi.ContentItemDataGUID);
break;
}

default:
break;
Expand Down Expand Up @@ -449,6 +463,128 @@ await MigratePageUrlPaths(ksSite.SiteGUID,
}
ksTrees = deferredTreeNodesService.GetNodes();
}

await LinkChildren(mappedSiteNodes);
}
}

private async Task LinkChildren(Dictionary<Guid, NodeMapResult> mappedSiteNodes)
{
var reusableItems = mappedSiteNodes.Values.Where(x => x.TargetClassInfo.IsReusableContentType());
var nonReusableItemsWithChildLinks = mappedSiteNodes.Values.Where(x => !x.TargetClassInfo.IsReusableContentType() && x.ChildLinks.Count != 0);
foreach (var item in nonReusableItemsWithChildLinks)
{
logger.LogError("Content item {ContentItemGuid} (original node {OriginalNodeGuid}) is not reusable, but specifies linked children. Add the content type to appsettings ConvertClassesToContentHub collection", item.ContentItemGuid, item.Node.NodeGUID);
}

// [content type name] -> { [field name] -> allowed referenced types }
Dictionary<string, Dictionary<string, (Guid? fieldGuid, HashSet<Guid> allowedTypes)>> referenceFields = [];

// Gather necessary reference fields and/or allowed referenced types
foreach (var parentItem in reusableItems)
{
if (!referenceFields.ContainsKey(parentItem.TargetClassInfo.ClassName))
{
referenceFields[parentItem.TargetClassInfo.ClassName] = [];
}
var childCollections = parentItem.ChildLinks.GroupBy(x => x.fieldName);
foreach (var collection in childCollections)
{
if (!referenceFields[parentItem.TargetClassInfo.ClassName].ContainsKey(collection.Key))
{
referenceFields[parentItem.TargetClassInfo.ClassName][collection.Key] = (null, []);
}
foreach (var (fieldName, node) in collection)
{
var childTargetType = mappedSiteNodes[node.NodeGUID].TargetClassInfo.ClassGUID;
referenceFields[parentItem.TargetClassInfo.ClassName][collection.Key].allowedTypes.Add(childTargetType);
}
}
}

// Merge required allowed referenced types with already existing ones
foreach (var classInfo in reusableItems.Select(x => x.TargetClassInfo).GroupBy(x => x.ClassName).Select(x => x.First()))
{
if (referenceFields.ContainsKey(classInfo.ClassName))
{
foreach (var field in new FormInfo(classInfo.ClassFormDefinition).GetFields<FormFieldInfo>().Where(x => x.DataType == "contentitemreference"))
{
if (referenceFields[classInfo.ClassName].ContainsKey(field.Name))
{
string existingSerialized = (string?)field.Settings[FormDefinitionPatcher.AllowedContentItemTypeIdentifiers] ?? "[]";
var existingAllowedTypes = JsonConvert.DeserializeObject<Guid[]>(existingSerialized)!.ToHashSet();
foreach (var existingAllowedType in existingAllowedTypes)
{
referenceFields[classInfo.ClassName][field.Name].allowedTypes.Add(existingAllowedType);
}
}
}
}
}

foreach (var classRefFields in referenceFields)
{
var classInfo = DataClassInfoProvider.ProviderObject.Get(classRefFields.Key);
var fi = new FormInfo(classInfo.ClassFormDefinition);
foreach (var field in classRefFields.Value)
{
var ffi = fi.GetFormField(field.Key);
if (ffi is null)
{
ffi = new FormFieldInfo
{
Guid = GuidHelper.CreateFieldGuid($"{field.Key.ToLower()}|{classInfo.ClassName}"),
AllowEmpty = true,
Caption = field.Key,
DataType = "contentitemreference",
Name = field.Key,
Enabled = true,
Visible = true,
System = false,
DefaultValue = null,
Settings = {
{ FormDefinitionPatcher.SettingsElemControlname, FormComponents.AdminContentItemSelectorComponent },
}
};
fi.AddFormItem(ffi);
}
classRefFields.Value[field.Key] = (ffi.Guid, field.Value.allowedTypes);
ffi.Settings[FormDefinitionPatcher.AllowedContentItemTypeIdentifiers] = JsonConvert.SerializeObject(field.Value.allowedTypes.ToArray());
}
classInfo.ClassFormDefinition = fi.GetXmlDefinition();
DataClassInfoProvider.ProviderObject.Set(classInfo);
}

foreach (var parentItem in reusableItems)
{
var parentMappedNode = mappedSiteNodes[parentItem.Node.NodeGUID];
foreach (var contentItemDataGuid in parentItem.ContentItemDataGuids)
{
var dataModel = new ContentItemDataModel
{
ContentItemDataGUID = contentItemDataGuid,
ContentItemDataCommonDataGuid = contentItemDataGuid,
ContentItemContentTypeName = parentMappedNode.TargetClassInfo.ClassName,
CustomProperties = []
};

foreach (var childCollection in parentItem.ChildLinks.GroupBy(x => x.fieldName))
{
var guidArray = childCollection.Select(x => new { Identifier = mappedSiteNodes[x.node.NodeGUID].ContentItemGuid }).ToArray();
string serializedValue = JsonConvert.SerializeObject(guidArray);
dataModel.CustomProperties[childCollection.Key] = serializedValue;
}

var importResult = await importer.ImportAsync(dataModel);
if (importResult is { Success: true })
{
logger.LogInformation("Imported linked children of content item {ContentItemGuid}", parentItem.ContentItemGuid);
}
else
{
logger.LogError("Failed to import linked children of content item {ContentItemGuid}: {Exception}", parentItem.ContentItemGuid, importResult.Exception);
}
}
}
}

Expand Down
12 changes: 8 additions & 4 deletions KVA/Migration.Tool.Source/Mappers/ContentItemMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ ContentFolderService contentFolderService
protected override IEnumerable<IUmtModel> MapInternal(CmsTreeMapperSource source)
{
(var cmsTree, string safeNodeName, var siteGuid, var nodeParentGuid, var cultureToLanguageGuid, string? targetFormDefinition, string sourceFormDefinition, var migratedDocuments, var sourceSite, bool deferred) = source;

logger.LogTrace("Mapping {Value}", new { cmsTree.NodeAliasPath, cmsTree.NodeName, cmsTree.NodeGUID, cmsTree.NodeSiteID });

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

var sourceNodeClass = modelFacade.SelectById<ICmsClass>(cmsTree.NodeClassID) ?? throw new InvalidOperationException($"Fatal: node class is missing, class id '{cmsTree.NodeClassID}'");
var mapping = classMappingProvider.GetMapping(sourceNodeClass.ClassName);
var targetClassGuid = sourceNodeClass.ClassGUID;
Expand All @@ -99,9 +100,14 @@ protected override IEnumerable<IUmtModel> MapInternal(CmsTreeMapperSource source
yield break;
}

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

var formerUrlPaths = GetFormerUrlPaths(cmsTree);

var directive = GetDirective(new ContentItemSource(cmsTree, sourceNodeClass.ClassName, mapping?.TargetClassName ?? sourceNodeClass.ClassName, sourceSite, formerUrlPaths));
var directive = GetDirective(new ContentItemSource(cmsTree, sourceNodeClass.ClassName, mapping?.TargetClassName ?? sourceNodeClass.ClassName, sourceSite, formerUrlPaths, childNodes));
directive.ContentItemGuid = contentItemGuid;
directive.TargetClassInfo = targetClassInfo;
directive.Node = cmsTree;
yield return directive;

if (directive is DropDirective)
Expand All @@ -117,7 +123,6 @@ protected override IEnumerable<IUmtModel> MapInternal(CmsTreeMapperSource source

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);
if (isMappedTypeReusable)
{
Expand Down Expand Up @@ -438,7 +443,6 @@ protected override IEnumerable<IUmtModel> MapInternal(CmsTreeMapperSource source
: JsonConvert.DeserializeObject<EditableAreasConfiguration>(hostPageCommonDataInfo.ContentItemCommonDataVisualBuilderWidgets)!;

// fill widget properties
var childNodes = modelFacade.Select<ICmsTree>("NodeParentID = @nodeID", "NodeOrder", new SqlParameter("nodeID", cmsTree.NodeID));
var childItemGUIDs = childNodes.Select(x => ContentItemInfo.Provider.Get(x.NodeGUID)?.ContentItemGUID).Where(x => x is not null).Select(x => x!.Value).ToList(); // don't presume that ContentItem matching the Node exists. Director might have dropped it

var widgetProperties = widgetDirective.ItemToWidgetPropertiesMapping?.Invoke(commonDataModel.CustomProperties.Concat(dataModel.CustomProperties).ToDictionary(), storeContentItem ? contentItemModel.ContentItemGUID : null, childItemGUIDs) ?? [];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Newtonsoft.Json.Linq;
using Migration.Tool.Source.Model;
using Newtonsoft.Json.Linq;

namespace Migration.Tool.Source.Mappers.ContentItemMapperDirectives;
internal partial class ContentItemActionProvider : IContentItemActionProvider
Expand All @@ -20,4 +21,11 @@ public void OverridePageTemplate(string templateIdentifier, JObject? templatePro
public void OverrideContentFolder(string displayNamePath) => Directive.ContentFolderOptions = new ContentFolderOptions(DisplayNamePath: displayNamePath);
public void RegenerateUrlPath() => Directive.RegenerateUrlPath = true;
public void OverrideFormerUrlPaths(IEnumerable<FormerPageUrlPath> formerPaths) => Directive.FormerUrlPaths = formerPaths;
public void LinkChildren(string fieldName, IEnumerable<ICmsTree> children)
{
foreach (var child in children)
{
Directive.ChildLinks.Add((fieldName, child));
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Kentico.Xperience.UMT.Model;
using CMS.DataEngine;
using Kentico.Xperience.UMT.Model;
using Migration.Tool.Source.Model;
using Newtonsoft.Json.Linq;

namespace Migration.Tool.Source.Mappers.ContentItemMapperDirectives;
Expand All @@ -9,6 +11,13 @@ internal abstract class ContentItemDirectiveBase : IUmtModel
public ContentFolderOptions? ContentFolderOptions { get; set; }
public bool RegenerateUrlPath { get; set; } = false;
public IEnumerable<FormerPageUrlPath>? FormerUrlPaths { get; set; }
public List<(string fieldName, ICmsTree)> ChildLinks { get; set; } = [];

#region Mapping results, used for postprocessing
public Guid ContentItemGuid { get; set; }
public DataClassInfo? TargetClassInfo { get; set; }
public ICmsTree? Node { get; set; }
#endregion

#region IUmtModel
// This interface is implemented only as means to allow yielding the directive out of content item mapper.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Migration.Tool.Source.Model;

namespace Migration.Tool.Source.Mappers.ContentItemMapperDirectives;
public record ContentItemSource(ICmsTree SourceNode, string SourceClassName, string TargetClassName, ICmsSite SourceSite, IEnumerable<FormerPageUrlPath> FormerUrlPaths);
public record ContentItemSource(ICmsTree SourceNode, string SourceClassName, string TargetClassName, ICmsSite SourceSite, IEnumerable<FormerPageUrlPath> FormerUrlPaths, IEnumerable<ICmsTree> ChildNodes);
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Newtonsoft.Json.Linq;
using Migration.Tool.Source.Model;
using Newtonsoft.Json.Linq;

namespace Migration.Tool.Source.Mappers.ContentItemMapperDirectives;
public interface IContentItemActionProvider
Expand All @@ -18,6 +19,15 @@ public interface IContentItemActionProvider
/// </summary>
void RegenerateUrlPath();
void OverrideFormerUrlPaths(IEnumerable<FormerPageUrlPath> formerPaths);

/// <summary>
/// Add references to child content items to a field. If the field doesn't exist, it will be created.
/// Child content item's content types will be added to the field's allowed content types as necessary.
/// If the field exists and is not of content item reference type, the migration will fail.
/// </summary>
/// <param name="fieldName">Name of the field to add the child content item references to</param>
/// <param name="children">One or more child objects passed by <see cref="ContentItemSource.ChildNodes"/></param>
void LinkChildren(string fieldName, IEnumerable<ICmsTree> children);
}

public record FormerPageUrlPath(string LanguageName, string Path, DateTime? LastModified = null);
5 changes: 5 additions & 0 deletions KVA/Migration.Tool.Source/Mappers/NodeMapResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using CMS.DataEngine;
using Migration.Tool.Source.Model;

namespace Migration.Tool.Source.Mappers;
public record NodeMapResult(ICmsTree Node, Guid ContentItemGuid, List<Guid> ContentItemDataGuids, DataClassInfo TargetClassInfo, List<(string fieldName, ICmsTree node)> ChildLinks);
1 change: 1 addition & 0 deletions Migration.Tool.CLI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,7 @@ The migration then converts the specified page types or custom tables to content

For advanced scenarios, you can use the extensibility feature to implement [customizations](../Migration.Tool.Extensions/README.md#custom-class-mappings) that map specific page types, custom tables or their individual fields to reusable content types. For example, this allows you to migrate multiple page types to a single content type.

To preserve relationship between page converted to reusable content item and its children, you can use [Custom child links](../Migration.Tool.Extensions/README.md#custom-child-links). This feature allows you to map children of the original page to a content item/page selector field of the target reusable content item.
## Convert page types to reusable field schemas

It is not possible to migrate any page types that inherit fields from other page types. However, to make the manual
Expand Down
2 changes: 2 additions & 0 deletions Migration.Tool.Common/Helpers/GuidHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public static class GuidHelper
public static readonly Guid GuidNsContentItem = new("EEBBD8D5-BA56-492F-969E-58E77EE90055");
public static readonly Guid GuidNsContentItemCommonData = new("31BA319C-843F-482A-9841-87BC62062DC2");
public static readonly Guid GuidNsContentItemLanguageMetadata = new("AAC0C3A9-3DE7-436E-AFAB-49C1E29D5DE2");
public static readonly Guid GuidNsContentItemReference = new("9FEDEA1C-C677-4026-B5E8-1A83EC501D06");

public static Guid CreateWebPageUrlPathGuid(string hash) => GuidV5.NewNameBased(GuidNsWebPageUrlPathInfo, hash);
public static Guid CreateWebPageFormerUrlPathGuid(string hash) => GuidV5.NewNameBased(GuidNsWebPageFormerUrlPathInfo, hash);
Expand All @@ -27,6 +28,7 @@ public static class GuidHelper
public static Guid CreateFolderGuid(string path) => GuidV5.NewNameBased(GuidNsFolder, path);
public static Guid CreateDataClassGuid(string key) => GuidV5.NewNameBased(GuidNsDataClass, key);
public static Guid CreateContentItemGuid(string key) => GuidV5.NewNameBased(GuidNsContentItem, key);
public static Guid CreateContentItemReferenceGuid(string key) => GuidV5.NewNameBased(GuidNsContentItemReference, key);
public static Guid CreateContentItemCommonDataGuid(string key) => GuidV5.NewNameBased(GuidNsContentItemCommonData, key);
public static Guid CreateContentItemLanguageMetadataGuid(string key) => GuidV5.NewNameBased(GuidNsContentItemLanguageMetadata, key);

Expand Down
Loading