Skip to main content

Sitecore

Sitecore XM Cloud Content Migration: Plan and Strategy

Istock 2148178472

Sitecore XM cloud content migration can be approached in various ways. The first thing that comes to mind when working on the Sitecore upgrade project to XM Cloud is content migration. Choosing the right method helps reduce migration time and avoid errors. I will outline the approach that best meets our client’s needs in this blog. We were provided with a master database backup that had all the content. Our task was to move Sitecore data, including media, from Sitecore 9.3 to XM Cloud.

We connected the legacy Sitecore database to the XM Cloud instance for migration. My colleague Martin Miles wrote a detailed blog on XM Cloud content migration: connecting the external database

Sitecore Items Data Migration to XM Cloud

Extracting items and their field data was relatively straightforward. We used PowerShell scripts to export item field data into CSV files. These CSVs were later imported into the XM Cloud using import scripts, building different page types.

We chose this approach due to a complete redesign of templates and renderings. The new XM Cloud setup did not reuse old templates, fields, or renderings. XM Cloud included fresh templates, fields, and renderings. This allowed us to map legacy page templates and fields to the new XM Cloud templates. This mapping helped us to import field values for different page types accordingly.

Our import scripts created key page types and populated the fields using field mappings. We created some standardized renderings for key page templates like Rich Texts, Accordion, etc. The scripts assigned data sources automatically to save content authors’ time. Only a few pages, like home, landing, and search, were created manually, which were unique. Most other pages were created using automation scripts.

XM Cloud runs in Docker containers in the local environment. So, we couldn’t access CSV files directly from the local file system. However, we have this covered by copying CSVs into \docker\deploy\platform\App_Data. This folder is part of the project’s root directory. These CSVs then became accessible within the local XM Cloud instance. We leveraged Sitecore Package Designer to package the CSV files to be installed in higher environments to run import scripts.

Sitecore Package Designer - Files Statically

Media Migration to XM Cloud

We already have a legacy Sitecore database connected to the XM Cloud instance. Using Sitecore’s out-of-the-box UI for bulk content migration between databases, we locally transferred media from the legacy database to the XM Cloud.

Controlpanel Moveitemtootherdb

Once the media folder with all the subfolders moved to XM Cloud, we have used a script to create media packages. My colleague Nick has written thorough blog on Exporting Media Items with Sitecore PowerShell Extensions to create packages of media.

The number of media packages depends on your database size. We had approximately 5GB of media items to migrate, so we created around 190 media packages for this migration. Package count also depends on the size of each media package. We limited each media package size to 25MB. Larger media packages take more time to upload and install. Sometimes, big packages get stuck without showing installation progress, which is frustrating.

In higher environments, we used the script below to upload and install the packages created locally.

$filepath = Receive-File -Path "C:\inetpub\wwwroot\App_Data\packages\"
Write-Host "Uploaded package Succesfully : " $filepath

# get file size in MB in PowerShell   
$size = (Get-Item -Path $filepath).Length/1MB
Write-Host $size "MB"
Write-Host "Installation Started......"
Install-Package -Path $filepath -InstallMode Merge -MergeMode Merge
Write-Host "Installation Completed - Package installed successfully"

In this way, we had migrated a whole media library to XM Cloud. This process was tedious and took a day to create and upload all packages.

I am sharing sample PowerShell scripts for extracting and importing Sitecore item data. The Sitecore IDs used in the scripts are not valid; you need to replace them as per your project’s Sitecore item IDs.

The PowerShell scripts are also available as a public GitHub Gist Export-Import SitecoreItemData SPE Scripts

Sitecore Item Data Extraction Script

<#
    .SYNOPSIS
       Generic Page data extraction
        
    .DESCRIPTION
        Gets Sitecore Items from legacy database and extracts field values.
        List could be exported to csv by OOTB feature of List View in SPE.
        
    .NOTES
        Akash Borkar | Perficient 
#>

function Write-LogExtended {
    param(
        [string]$Message,
        [System.ConsoleColor]$ForegroundColor = $host.UI.RawUI.ForegroundColor,
        [System.ConsoleColor]$BackgroundColor = $host.UI.RawUI.BackgroundColor
    )

    Write-Log -Object $message
    Write-Host -Object $message -ForegroundColor $ForegroundColor -BackgroundColor $backgroundColor
}

# Function for getting item names from pipe-separated item ids
function GetItemNamesFromIds {
    param(
        [System.String] $ids
    )
    
    if($ids.Contains("|"))
    {
        # Split the string by pipe and clean up each GUID
        $guids = $ids.Split("|") | ForEach-Object { $_.Trim(' {}') }
        [Sitecore.Text.ListString]$nameArr = ""

        foreach ($id in $guids) {
            $formattedId = "{0}{1}{2}" -f '{', $id, '}'
    
            $Id = [Sitecore.Data.ID]::Parse($formattedId) 
            $item = Get-Item -Path xyz: -ID $Id
                    
            if ($item -ne $null -and !$nameArr.Contains($item.Name)) {
                $nameArr.Add($item.Name) | Out-Null
            }
        }
        
        # Join the names with pipe separator and return the result
        $names = [System.String]::Join("|", $nameArr)
        return $names
    }
    else
    {
        $item = Get-Item -Path xyz: -ID $ids
            return $item.Name
    }
}

#Function for getting datasource item, which is further used for extracting field value & assigning to new rendering
function Get-DatasourceItem{
    param(
         [System.String] $path
        )
        $datasourceItem = $sourceDatabase.GetItem($path)
        return $datasourceItem
}


Write-LogExtended " "
Write-LogExtended "Generic Pages Data Extraction"
Write-LogExtended "-----------------------------------------------------------------"

$processedItems = [System.Collections.Generic.List[PSCustomObject]]::new()
$nonprocessedItems = [System.Collections.Generic.List[PSCustomObject]]::new()
$sourceDatabase = Get-Database "xyz"

$parentPath = Show-Input -Prompt "Please enter the Sitecore item path for getting children"
if($parentPath -ne $null)
{
    Write-Host "Selected path: $parentPath"

    #Get all child items based on provided path. /sitecore/content/XYZ/Home/Generic
    $nodePath = "xyz:" + $parentPath
    $items = Get-ChildItem -Path $nodePath -Recurse | Where-Object { $_.TemplateName -eq 'Generic Page'}
    
    Write-LogExtended "Total child items: $($items.Count)  Path: $($nodePath)" -ForegroundColor Green
    
    foreach($sourceItem in $items)
    {
        if($sourceItem -ne $null){
            
            #Retrieve RTE
            $rts = Get-Rendering -Item $sourceItem -Device $defaultLayout -FinalLayout | Where-Object { $_.ItemID -eq "{278F7B0D-98F4-4873-9B7B-940082158E4A}"}
            [Sitecore.Text.ListString]$rtArr = ""
            
            if($rts -ne $null)
            {
                foreach($rt in $rts)
                { 
                  $item = $sourceDatabase.GetItem($rt.Datasource)
                  if($item -ne $null -and $item["Text"] -ne "")
                  {
                      $rtArr.Add($item["Text"]) | Out-Null
                  }
                }
            }
            
            
            #Retrieve Accordion
            $accordion = Get-Rendering -Item $sourceItem -Device $defaultLayout -FinalLayout | Where-Object { $_.ItemID -eq "{165B5ECC-E6A0-4B41-AA23-D28FA5A9BF68}"}
            $accordionCount = 0
            [Sitecore.Text.ListString]$titleArr = ""
            [Sitecore.Text.ListString]$descArr = ""
            
            if($accordion -ne $null)
            {
                foreach ($renderingItem in $accordion) 
                {
                    if($renderingItem.Datasource -ne "")
                    {
                        $rendering = Get-Item -Path xyz: -Id $renderingItem.Datasource
                        if($rendering.HasChildren)
                        {
                            $accdChildItems = Get-ChildItem -Path xyz: -ID $rendering.ID
                            foreach($item in $accdChildItems)
                            {
                                if($item["Title"] -ne "" -and $item["Description"] -ne "")
                                {
                                      $titleArr.Add($item["Title"]) | Out-Null
                                      $descArr.Add($item["Description"]) | Out-Null
                                }
                            }
                        $accordionCount++;
                        }
                    }
                }
            }
                
            #Retrieve values of multilist field named Categories
            $categories = $sourceitem["Categories"]
            $categoriesnames = ""
            if($categories -ne "" -and $categories -ne $null)
            {
                $categoriesnames = GetItemNamesFromIds -ids $categories
            }
            
            
            try{
                $processedItems.Add(
                    [PSCustomObject]@{
                        Name = $sourceItem.Name
                        Id = $sourceItem.Id
                        Path = $sourceItem.Paths.FullPath
                        Title = $sourceItem["Title"]
                        HeaderTitle = $sourceItem["Header Title"]
                        Summary = $sourceItem["Summary"]
                        Image = $sourceItem["Image"]
                        OGImage = $sourceItem["Media Image"]
                        Categories = $categoriesnames
                        HasRTE = $rtArr.Count
                        RichText1 = $richText
                        RichText2 = $rtArr[1]
                        RichText3 = $rtArr[2]
                        
                        HasAccordion = $accordionCount
                        AccTitle1 = $titleArr[0]
                        AccDesc1 = $descArr[0]
                        AccTitle2 = $titleArr[1]
                        AccDesc2 = $descArr[1]
                        AccTitle3 = $titleArr[2]
                        AccDesc3 = $descArr[2]
                        AccTitle4 = $titleArr[3]
                        AccDesc4 = $descArr[3]
                        
                    }
                )
                        
            Write-LogExtended "Added data for $($sourceItem.Name), Path: $($sourceItem.Paths.FullPath) " -ForegroundColor Green
            }
            catch{
                Write-Host "Error occured" -BackgroundColor DarkRed
                            
                $nonprocessedItems.Add(
                    [PSCustomObject]@{
                        Name = $sourceItem.Name
                        Id = $sourceItem.Id
                        Path = $sourceItem.Paths.FullPath
                    }
                )
            }
        }
        else
        {
            Write-LogExtended "No Item found in csv for SourceURL: $($row.SourceURL)" -ForegroundColor RED
        }
    }
    
    $processedItems | Show-ListView -PageSize 15000 -Property  Name, Id, Path, Title, HeaderTitle, Summary, Categories, Image, OGImage, 
                                                                HasRTE, RichText1, RichText2, RichText3, HasAccordion, AccTitle1, AccDesc1, AccTitle2, AccDesc2, AccTitle3, 
 AccDesc3, AccTitle4, AccDesc4
                                                             
    $processedItems | export-csv -Path "C:\inetpub\wwwroot\App_Data\Process.csv" -NoTypeInformation                                                  
    $nonprocessedItems | Show-ListView -PageSize 15000 -Property  Name, Id, Path
}
else
{
        Write-Host "Path is not provided : $parentPath"
}

Sitecore Item Import Script

<#
    .SYNOPSIS
       Generic Pages Import
        
    .DESCRIPTION
        Iterate through CSV, create siteore items for generic pages and Populate fields. Generate List of processed and Non processed items.
        
    .NOTES
        Akash Borkar | Perficient 
#>

function Write-LogExtended {
    param(
        [string]$Message,
        [System.ConsoleColor]$ForegroundColor = $host.UI.RawUI.ForegroundColor,
        [System.ConsoleColor]$BackgroundColor = $host.UI.RawUI.BackgroundColor
    )

    Write-Log -Object $message
    Write-Host -Object $message -ForegroundColor $ForegroundColor -BackgroundColor $backgroundColor
}


Write-LogExtended " "
Write-LogExtended "Generic Pages Import"
Write-LogExtended "-----------------------------------------------------------------"

# Prompt for the file path
$filePath = Show-Input -Prompt "Please enter the full path to the CSV file"
if($filePath -ne $null)
{
Write-Host "Selected file: $filePath"

# Import the CSV file
$csvData = Import-Csv -Path $filePath | Where-Object { -join $_.psobject.Properties.Value }

Write-LogExtended "CSV file read successfully" -ForegroundColor Green

    if($csvData -ne $null)
    {
        $processedItems = [System.Collections.Generic.List[PSCustomObject]]::new()
        $NonProcessedItems = [System.Collections.Generic.List[PSCustomObject]]::new()
        $database = Get-Database "master"
        $placeholder = "/headless-main/container-1"
        
        foreach ($row in $csvData){
            
            #Generic Page branch
            $branchTemplateId = [Sitecore.Data.ID]::Parse("{8032FE9E-3CD1-4E80-8377-66BBF74F839E}")
            
            # Extract the desired item name, parent item and template id from the URL
            $itemName = $row.Name
            $parentItemPath = $row.Path -Replace $itemName, ""

            # Get the parent item
            $parentItem = $database.GetItem($parentItemPath)
            
            if ($parentItem){
                # Check if the item already exists
                $existingItemPath = "$($parentItem.Paths.FullPath)/$itemName"
                $itemExists = Test-Path -Path $existingItemPath
                
                if (-not $itemExists){
                    $item = [Sitecore.Data.Managers.ItemManager]::AddFromTemplate($itemName, $branchTemplateId, $parentItem)
                    
                    if ($item -eq $null) {
                    Write-LogExtended "Unable to create new item - $($itemName) - in Language en" -ForegroundColor Red
                    
                        $NonProcessedItems.Add(
                        [PSCustomObject]@{
                            ID = $row.ID
                            Name    = $row.Name
                            Path = $row.Path
                        })
                    }
                    
                    if($item -ne $null){
                        $item.Editing.BeginEdit()
                        
                        $item["Title"] = $row.Title
                        
                        #Meta Properties/OG
                        $item["OpenGraphTitle"] = $row.Title
                        $item["OpenGraphDescription"] = $row.Summary
                        $item["MetaDescription"] = $row.Summary
                        $item["TwitterDescription"] = $row.Summary 
                        $item["TwitterImage"] = $row.OGImage
                        
                        $item.Editing.EndEdit() | Out-Null
                        
                        
                        $datasourcePath =  $item.Paths.FullPath + "/Data/"
                        #Create and Populate Rich Text (Max RT are 3 as we had extracted 3 RTEs)
                        if($row.RichText1 -ne "")
                        {
                             For($i=1; $i -lt 4; $i++) 
                                { 
                                    $propname = "RichText$i"
                                    if($row.$propname -ne "")
                                    {
                                        $dsitem = New-Item -Path $datasourcePath -Name "Text $i" -ItemType "{4FBDBF79-C7D6-42F1-8048-D5E70D6167D5}"
                                        $dsitem.Editing.BeginEdit()
                                        $dsitem.Text =  $row.$propname
                                        $dsitem.Editing.EndEdit() | Out-Null
                                        
                                        #Create and Set Rich text Rendering
                                        $rendering = get-item -path master: -id {EF82E4AE-C274-40D4-837C-B3E1BF180CCC}
                                        $renderinginstance = $rendering | new-rendering -placeholder $placeholder
                                        $renderinginstance.datasource = $dsitem.id
                                        Add-Rendering -Item $item -placeholder $placeholder -instance $renderinginstance -finallayout
                                        $item.Editing.beginedit()
                                        $item.Editing.endedit() | out-null
                                    }
                                }
                        }
                        
                        
                        #Create and Populate Accrd datasource item (Max Acc are 4)
                        if($row.AccTitle1 -ne "" -and $row.AccDesc1 -ne "")
                        {
                            $accDatasourcePath =  $item.Paths.FullPath + "/Data/"
                            #Accordion
                            $Accitem = New-Item -Path $accDatasourcePath -Name "Accordion" -ItemType "{D482D45C-4248-46C8-BDD5-DE7C2255C52A}"
                            $Accitem.Editing.BeginEdit()
                            $Accitem.Title =  "Accordion"
                            $Accitem.Editing.EndEdit() | Out-Null
                            
                            #Create and Set Acc rendering
                            $rendering = Get-Item -Path master: -ID {3341A94D-42C9-4EE3-8A25-51D8B437982B} #Accordion
                            $renderingInstance = $rendering | New-Rendering -Placeholder $placeholder
                            $renderingInstance.Datasource = $Accitem.ID
                            Add-Rendering -Item $item -PlaceHolder $placeholder -Instance $renderingInstance -FinalLayout
                            
                            For($i=1; $i -lt 5; $i++) 
                            { 
                                $titlename = "AccTitle$i"
                                $descname = "AccDesc$i"
                                if($row.$titlename -ne "" -and $row.$descname -ne "")
                                {
                                    #Acc Panel
                                    $dsitem = New-Item -Path $Accitem.Paths.FullPath -Name $row.$titlename -ItemType "{B50C502C-2740-44C8-A63E-E9E4AF4BAA4B}"
                                    $dsitem.Editing.BeginEdit()
                                    $dsitem.Title =  $row.$titlename
                                    $dsitem.Content =  $row.$descname
                                    $dsitem.Editing.EndEdit() | Out-Null
                                    
                                    #Create and Set Acc panel rendering
                                    $rendering = Get-Item -Path master: -ID {7614DFFF-6735-4BA5-929A-A82FBC91DB25} #Acc Panel
                                    $renderingInstance = $rendering | New-Rendering -Placeholder "/headless-main/container-1/accordion-panels-1"
                                    $renderingInstance.Datasource = $dsitem.ID
                                    Add-Rendering -Item $item -PlaceHolder "/headless-main/container-1/accordion-panels-1" -Instance $renderingInstance -FinalLayout
                                    $item.Editing.BeginEdit()
                                    $item.Editing.EndEdit() | Out-Null
                                    
                                    Write-LogExtended "Added Accordion datasource to New Item - $($item.Name) at $($dsitem.Paths.FullPath)" -ForegroundColor Green
                                }
                            }
                        }
                        
                        $ManualWork = "No"
                        if(($row.HasRTE -gt 3) -or ($row.HasAccordion -gt 4))
                        {
                            $ManualWork = "Yes"
                        }
                        
                        Write-LogExtended "Created New Item - $($itemName) at $($parentItemPath)" -ForegroundColor Green
                        $processedItems.Add(
                        [PSCustomObject]@{
                            Name    = $item.Name
                            Id = $item.ID
                            NewPath = $item.Paths.FullPath
                            HasRTE = $row.HasRTE
                            HasAccordion = $row.HasAccordion
                            ManualWork = $ManualWork 
                        })
                    }
                }
                else 
                {
                    Write-LogExtended "Item $($itemName) already exists at $($parentItemPath) " -ForegroundColor Yellow
                }
            }
            else 
            {
                Write-LogExtended "Parent item not found: $parentItemPath" -ForegroundColor Red
            }
        }
        
    $processedItems | Show-ListView -PageSize 2000 -InfoTitle "Processed Items" -Property  Name, Id, NewPath, HasRTE, HasAccordion, ManualWork
        $processedItems | export-csv -Path "C:\inetpub\wwwroot\App_Data\GenericPagesReport.csv" -NoTypeInformation
    $NonProcessedItems | Show-ListView -PageSize 2000 -InfoTitle "Non Processed Items" -Property  ID, Name, Path
    }
}
else
{
    Write-Host "No file selected : $filePath"
}

I kept the script limited to extracting some fields and renderings; however, it gives a fair idea of how to extract data and import.

Conclusion

The PowerShell scripts you will write will be based on project requirements, template naming, rendering, and field mappings for your key page templates. When we first started migrating, we had a hard time figuring out the approach and connecting the dots. In this blog, I wanted to make sure the plan is clear. In my next blog Sitecore PowerShell commands – XM Cloud Content Migration, I will give the code snippets of the most widely used PSE commands used in Sitecore content migration.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Akash Borkar

Akash Borkar is a passionate IT professional with a strong focus on continuous learning and knowledge sharing. Currently serving as a Senior Technical Consultant at Perficient, he brings hands-on experience in technologies like .NET Core, Sitecore, and Optimizely CMS.

More from this Author

Categories
Follow Us