Thursday, November 11, 2021

Sitecore CM: Limiting the number of versions in Sitecore

" If you found yourself searching for a solution for limiting the number of versions that get created by your content authors, you probably have come across the old blog post by John West (Rules Engine Actions to Remove Old Versions in the Sitecore ASP.NET CMS). The blog post is awesome like all blog posts that John has written. It is over 8 years ago and some things did change in Sitecore since then. So I figured I would write an update to it as I wasn't able to find one.

The code part is still valid. I did run into an exception in the "while" loop, so I changed the code in the Apply function to the following:

public override void Apply(T ruleContext) { 

    Assert.ArgumentNotNull(ruleContext, "ruleContext");     

    Assert.ArgumentNotNull(ruleContext.Item, "ruleContext.Item"); 

    // for each language available in the item 

    foreach (Language lang in ruleContext.Item.Languages) { 

        var item = ruleContext.Item.Database.GetItem(ruleContext.Item.ID, lang); 

        if (item == null) { continue; } 

        var versionsToProcess = item.Versions.GetOlderVersions().OrderByDescending(i => i.Version.Number).Skip(this.MinVersions); 

        foreach(var version in versionsToProcess) { 

            Assert.IsNotNull(version, "version"); 

            if (this.MinUpdatedDays < 1 || version.Statistics.Updated.AddDays(this.MinUpdatedDays) < DateTime.Now) {

                this.HandleVersion(version); 

            

        

    

}

There are a few things changed in the Rules area.

After you create all four classes for the rule actions (MinVersionsAction, RecycleOldVersions, ArchiveOldVersions, and DeleteOldVersions) the following actions will need to be performed in Sitecore:

  • Go to "/sitecore/system/Settings/Rules/Definitions/Elements" and duplicate any of the elements. Name the new element "Item Version Limiting" or any other way you choose.
  • Remove all conditions and actions if you duplicated an existing element that had them.
  • Create three Actions using Action data template as John described: Archive Versions Beyond, Delete Versions Beyond, Recycle Versions Beyond.
  • In the Text field put the values that John provided:
Archive Versions Beyond:
archive versions beyond [MinVersions,positiveinteger,,number] older than [MinUpdatedDays,positiveinteger,,number] days

Delete Version Beyond:
delete versions beyond [MinVersions,positiveinteger,,number] older than [MinUpdatedDays,positiveinteger,,number] days

Recycle Versions Beyond:
recycle versions beyond [MinVersions,positiveinteger,,number] older than [MinUpdatedDays,positiveinteger,,number] days
  • In the Type field put the reference to the corresponding class you created.

  • To get the actions to appear in the rule selection window you'll have to add a new Tag called "Item Version Limiting" under "/sitecore/system/Settings/Rules/Definitions/Tags/Item Version Limiting". You can duplicate an existing tag for that.
  • In "Default" item under "/sitecore/system/Settings/Rules/Definitions/Elements/Item Version Limiting/Tags" update the reference to the newly created tag.

  • In the Default item under "/sitecore/system/Settings/Rules/Item Saved/Tags" in the Tags field add a reference to the newly created "Item Version Limiting" tag.

  • Go to the "/sitecore/system/Settings/Rules/Item Saved/Rules" and add a new rule. Define the rule condition and action in the Rules field.



Condition is mandatory and the most suitable one I found without writing my own was the "where the number of versions compares to number" one from the Item Version CM.


Friday, November 5, 2021

Sitecore CDP: Triggered Experience with decision model needs offers

 I was fortunate to be able to take Sitecore CDP training with Sarah O'Reilly (https://www.sitecore.com/products/customer-data-platform). There were a lot of interesting features in Sitecore CDP that we got to play with during this training. If you have a chance to take, do so, you won't regret it.

During day four of the training we ran into an issue when our triggered full stack experience was failing with "NO DECISION" message. The full stack experience used a decision model that simply returned a list of products in abandoned cart and had only one programmable that returned that list. Without a decision model the experience works fine and send the email, but if a decision model is added it fails.



After further investigation into the log for each failed execution it was clear that the experience is expecting the offers even though there were no offers to return. All we wanted to do is to generate a list of products in abandoned cart and send guest an email with the list of those products.

Clicking on the "View Log" brings back the details about a specific execution which allowed me to debug it further. As as you can see below the decision model returns results, and everything should be working, but it didn't. 


I figured why not to compare the executions without the decision models that succeeded with the ones with the model that are returning "NO DECISION". And I noticed that one particular property is null in the second case:



The next step was adding logging to my programmable. To do that I added "print()" statement to render the guest session details and made this variant of the decision model live. In the log property of my failed execution I started seeing values and one of those values was "status=ERROR, error=No offers returned, errorCategory=NO_DECISION".

I started suspecting that the experience expects to get some sort of offers from the decision model. So I added a decision model to the programmable that always returns an offer. It doesn't matter what the offer is as long as it is an offer. After I made that variant of the decision model live my triggered experience started working.

Sarah O'Reilly confirmed that while this limitation of Sitecore CDP was recently removed in interactive experience, it still exists in triggered one. Just something to be aware of.

Monday, October 18, 2021

Coveo for Sitecore is throwing ACCESS_DENIED

 Dear future me and everyone who is in the same boat,

So you restored a master database from another environment and now are trying to get Coveo working on CM. You open the Indexing Manager and it tells you that there is something wrong with your indexes. You go to Coveo Cloud Organization tab and try to login into Coveo Cloud, but get a 404 error because the Login button has a relative link to your CM instance instead of Coveo cloud url. When you look at the logs, you see the ACCESS_DENIED error.

The issue is with the encryption key and the fact that it is different. The key is stored in the master database in Properties table, and when you restored the database, the API key that your CM passed to Coveo is no longer valid.

To fix this you'll have to create a temporary API Key in Coveo Cloud, get your CM working again, login into Coveo Could again, which will fix the key in your config files. Then you'll be able to do delete the temporary key and proceed with copying the config file to CDs if you need to. 

Coveo Support provided instructions on how to create a temporary key in the following article:

https://connect.coveo.com/s/article/4739

Tuesday, October 12, 2021

What is Boxever and why it is worth looking into it?

 As you probably know Sitecore has made a few acquisitions in the last year that promise to alter the Sitecore platform significantly.  Customer Data Platform called Boxever was one of the first two companies that got acquired by Sitecore. The first glimpse at the platform created a lot of buzz in Sitecore community which is not surprising. The platform provides capabilities that Sitecore xDB doesn’t, and in my humble opinion, it will replace Sitecore xDB in the future.

Why is Boxever worth looking into?

Client-Side Execution

First and foremost, Boxever CDP tracking and personalization is happening on the front-end. It means that as a developer you need to include the JavaScript reference to the Boxever library, and you are ready to roll. The way you include the reference to the library It is very similar to Google Analytics, Adobe Analytics, and many other services. If you are thinking of implementing a Jamstack solution, Boxever is a perfect solution for analytics and personalization.

 Connections to any system over REST API

If you need to retrieve the list of products from a separate commerce system and display different collection of products based on the audience or other criteria, you can do that with Boxever. It provides the Data sources feature that allows you to create connections to various systems of your choosing.

Personalization

You can personalize areas of the page based on the user data and the rules that you define in Boxever admin. Under the hood, all Boxever JavaScript does is it injects the html that you defined in the admin into a specific area of your page. Based on the rules you define; it can inject different html. The only challenge is the delay. Since the personalization is running on the front end and there is a call made to Boxever service to validate the rules and provide corresponding html, it takes a little bit of time to render the Web Experience. Rendering personalized main navigation with Boxever might not be the best solution because of that, but if you alter areas of the page that are below the page fold, for example, it works perfectly.

 APIs

Boxever CDP comes with a number of API endpoints that allows you alter user data that gets tracked by the system, retrieve the user data in various ways, manipulate connections, etc. Overall, it is a pretty long list of APIs that you can use in various cases.

Learning Boxever

Sitecore provided a Boxever course on the Learning Portal that would give you a basic understanding of the platform. Don’t expect it to go into developer implementation details. There is a separate documentation website for that, which is pretty useful.

https://developer.boxever.com/reference/whats-new

Friday, August 27, 2021

PowerShell 7 with Sitecore PowerShell Module

 Recently one of my project required a migration process to be run on a large number of records. Ideally, I wanted to run the processing for individual Sitecore items in parallel. Unfortunately, SPE doesn't support "-Parallel" parameter on ForEach-Object, so I had to come up with a different approach. I learnt that PowerShell 7 supports this parameter and figured that it might be possible to combine the PowerShell 7 script with execution of SPE script using remoting. Below is the result of this effort, if you are interested.

There is a function that makes a call to a URL to retrieve an image, downloads it into a folder in Sitecore Data Folder, and uploads it into the Media Library. If the process is successful, the function returns the created media item and image field gets updated with the reference to the newly created image. 

Import-Module -Name SPE
$credential = Get-Credential
$session = New-ScriptSession -Username admin -Password b -ConnectionUri https://www-local-9sc.dev.local -Credential $credential 

$apiBaseUrl = "https://[service base url]"
$groupEndpoint = $apiBaseUrl
$headers = @{"X-AUTH-TOKEN" = "#############"}

Write-Output "###############################################################"
Write-Output "################# Starting Import #####################"
Write-Output "###############################################################"

while $groupEndpoint -ne $null) {
    Write-Output "______________________________________________________________________"
    Write-Output $groupEndpoint 
    Write-Output "__________________________________________________________________________"

    $Response = Invoke-RestMethod -Uri $groupEndpoint -Method "GET" -Headers $headers -UseBasicParsing
    $groupEndpoint = $Response.meta.next_link   
        
    $Response.data | ForEach-Object -Parallel {

        $script = {            
            Write-Output "###############################################################"
            Write-Output "$($params.id)"
            Write-Output "---------------------------------------------------------------"

            Write-Log "###############################################################"
            Write-Log "$($params.id)"
            Write-Log "---------------------------------------------------------------"

            Set-Location -Path "master:"

            function New-MediaItem{
                [CmdletBinding()]
                param(
                    [Parameter(Position=0, Mandatory=$true, ValueFromPipeline=$true)]
                    [ValidateNotNullOrEmpty()]
                    [string]$filePath,

                    [Parameter(Position=1, Mandatory=$true)]
                    [ValidateNotNullOrEmpty()]
                    [string]$mediaPath)

                $mco = New-Object Sitecore.Resources.Media.MediaCreatorOptions
                $mco.Database = [Sitecore.Configuration.Factory]::GetDatabase("master");
                $mco.Language = [Sitecore.Globalization.Language]::Parse("en");
                $mco.Versioned = [Sitecore.Configuration.Settings+Media]::UploadAsVersionableByDefault;
                $mco.Destination = "$($mediaPath)/$([System.IO.Path]::GetFileNameWithoutExtension($filePath))";

                $mc = New-Object Sitecore.Resources.Media.MediaCreator
                return $mc.CreateFromFile($filepath, $mco);
            }

            function Retrieve-Media{
                [CmdletBinding()]
                param(
                    [Parameter(Position=0, Mandatory=$true, ValueFromPipeline=$true)]
                    [ValidateNotNullOrEmpty()]
                    [string]$sourceUrl,
        
                    [Parameter(Position=0, Mandatory=$true, ValueFromPipeline=$true)]
                    [ValidateNotNullOrEmpty()]
                    [string]$profileId)
        
                $imageFilename = [System.IO.Path]::GetFileName($sourceUrl)
                $imageFileDest = "$AppPath$dataFolder\images_temp\$profileId\$imageFilename"
                #Write-Host $sourceUrl  $imageFileDest
                    
                New-Item -ItemType Directory -Path "$AppPath$dataFolder\images_temp\$profileId" -Force
    
                $wc = New-Object System.Net.WebClient
                $imageFile = $wc.DownloadFile($sourceUrl, $imageFileDest);
                $imageName = $imageFilename.TrimEnd(".jpg")
                $imagePath = "$mediaFolderPath/$mediaFolderName/$imageFilename"
                    
                $imageMediaItem = $null
                if(Test-Path $imagePath) {
                   Write-Host "Image $imagePath already exists... skipping"
                   $imageMediaItem = Get-Item -Path $imagePath
                   #Write-Host "Existing media item id is " $imageMediaItem.ID
                }
                else
                {       
                   #Write-Host "Uploading Image $imagePath ..."
                   $imageMediaItem = New-MediaItem $imageFileDest "$mediaFolderPath/$mediaFolderName"
                   #Write-Host "Uploading Image $imagePath ... done. " $imageMediaItem.ID
                }
                        
                #Write-Host "Removing file $imageFileDest"
                Remove-Item -Path $imageFileDest
                        
                return $imageMediaItem
            }

            $websiteItem = [Custom.Feature.Agents.Services.AgentWebsiteDashboardService]::GetExistingItem($rootItem, $params.id)

            if ($websiteItem -eq $null) {
                Write-Output "There is no agent website item for this id value. Creating new agent website for $($params.id)"
                Write-Log "Creating new agent website for $($params.id)"
                $websiteItem = [Custom.Feature.Agents.Helpers.PowershellHelper]::CreateAgentWebsite($params.id)
            }

            if ($websiteItem -ne $null) {
                Write-Output "Agent Site ID $($websiteItem.ID.Guid)"
                Write-Log "Agent Site ID $($websiteItem.ID.Guid)"
#### Profile Photo Start
                $mediaFolderName = $_.first_name + " " + $_.last_name
                $mediaFolderPath = $mediaLibraryPath
                    
                $mediaFolderExists = Test-Path -Path $mediaFolderPath
    
# Creating the folder for media assets in Media Library
                if ($mediaFolderExists -eq $false) {
                   New-Item -Path $mediaFolderPath -Name $mediaFolderName -ItemType "System/Media/Media folder"
                   Write-Output "Created Media Folder $($mediaFolderPath/$mediaFolderName)"
                   Write-Log "Created Media Folder $($mediaFolderPath/$mediaFolderName)"
                } 

                if ($_.profile_photo -ne $null) {
                    $profilePhotoMediaItem = Retrieve-Media -sourceUrl $_.profile_photo -profileId $profileId 
                } else {
                    Write-Output "Profile Photo is empty in response"
                    Write-Log "Profile Photo is empty in response" -Log Error
                }
                    
                if ($profilePhotoMediaItem -ne $null -and $primaryAgentItem -ne $null) {
                    $primaryAgentItem.Editing.BeginEdit()
                    $primaryAgentItem.Fields["Profile Photo"].Value = [string]::Format("<image mediaid='{0}' />",$profilePhotoMediaItem.ID)
                    $primaryAgentItem.Editing.EndEdit()

                    Write-Output "Profile Photo Updated"
                    Write-Log "Profile Photo Updated"
                 }
#### Profile Photo End

                 #### Website Item Update Start
                 $websiteItem.Editing.BeginEdit()                    
                 ### field updates got here
                 $websiteItem.Editing.EndEdit()

                 Write-Output "Website Item updated"
                 Write-Log "Website Item updated"
                        
                }
            }
            else {
                Write-Output "There is no agent website item for this gname value - $($params.id) and it wasn't created by this script"
                Write-Log "There is no agent website item for this gname value - $($params.id) and it wasn't created by this script."  -Log Error
            }
        }

        $args = @{ "id" = $_.id; "apiBaseUrl" = $using:apiBaseUrl; "headers" = $using:headers }

        Invoke-RemoteScript -Session $using:session -ScriptBlock $script -ArgumentList $args #-AsJob
              
    }  -ThrottleLimit 4
}

Stop-ScriptSession -Session $session

Write-Output "###############################################################"
Write-Output "################# Finished Import #####################"
Write-Output "###############################################################"