Sitecore smart translation tool with SPE and Azure Cognitive Services (AI)

In my previous posts about images cropping, I’ve used Azure Cognitive Services (Vision) for managing media cropping in a smart way. Now, I’m sharing another usage of Azure Cognitive Services (Language) for building a Powershell tool that makes possible to translate your Sitecore content in a quick and easy way.

Handling item versioning and translation from the Sitecore content editor is a kinda tedious work for editors, especially when it comes to manually creating localized content for your site.

The idea of the PSE tool is to make the editor’s life easier, so in several clicks can achieve the language version creation of the item (including subitems and datasources) and also populate the items with translated content!

Azure Translator – An AI service for real-time text translation

Translator is a cloud-based machine translation service you can use to translate text in near real-time through a simple REST API call. The service uses modern neural machine translation technology and offers statistical machine translation technology. Custom Translator is an extension of Translator, which allows you to build neural translation systems. The customized translation system can be used to translate text with Translator or Microsoft Speech Services. For more info please refer to the official documentation.

About the tool

As I mentioned before, this tool is based on SPE, so it’s easy to integrate on your Sitecore instance. I’ll share the full implementation details but also the code and packages. The service API layer has been implemented on .NET.

The context menu script
Demo

Creating the Azure service

Before proceeding with the implementation, let’s see how to create the Translator service in Azure. The steps are very straightforward as usual when creating such resources.

  • Login to Azure portal (https://portal.azure.com/) and click on create new resource.
  • Search for Translator and finally click on the create button.
Azure Translator Resource
  • Fill the required options and choose a plan. For testing purposes there is a free plan!.
  • Free plan limits: 2M chars of any combination of standard translation and custom training free per month.
  • More details about the available plans here.
Azure Translator Options
  • That’s it! You have your translator service created, now just take a look at the keys and endopint section, you will need it for updating in your config file:
Keys and Endopint

Service implementation (C#)

TranslatorService.cs

This is the service that communicates with the Azure API, it’s quite basic and straightforward, you can also find examples and documentation in the official sites.

using System;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Sitecore.Cognitive.Translator.PSE.Caching;
using Sitecore.Cognitive.Translator.PSE.Models;
using Sitecore.Configuration;

namespace Sitecore.Cognitive.Translator.PSE.Services
{
    public class TranslatorService : ITranslatorService
    {
        private readonly string _cognitiveServicesKey = Settings.GetSetting($"Sitecore.Cognitive.Translator.PSE.TranslateService.ApiKey", "");
        private readonly string _cognitiveServicesUrl = Settings.GetSetting($"Sitecore.Cognitive.Translator.PSE.TranslateService.ApiUrl", "");
        private readonly string _cognitiveServicesZone = Settings.GetSetting($"Sitecore.Cognitive.Translator.PSE.TranslateService.ApiZone", "");

        public async Task<TranslationResult[]> GetTranslatation(string textToTranslate, string fromLang, string targetLanguage, string textType)
        {
            return await CacheManager.GetCachedObject(textToTranslate + fromLang + targetLanguage + textType, async () =>
            {
                var route = $"/translate?api-version=3.0&to={targetLanguage}&suggestedFrom=en";

                if (!string.IsNullOrEmpty(fromLang))
                {
                    route += $"&from={fromLang}";
                }

                if (!string.IsNullOrEmpty(textType) && textType.Equals("Rich Text"))
                {
                    route += "&textType=html";
                }

                var requestUri = _cognitiveServicesUrl + route;
                var translationResult = await TranslateText(requestUri, textToTranslate);

                return translationResult;
            });
        }

        async Task<TranslationResult[]> TranslateText(string requestUri, string inputText)
        {
            var body = new object[] { new { Text = inputText } };
            var requestBody = JsonConvert.SerializeObject(body);

            using (var client = new HttpClient())
            using (var request = new HttpRequestMessage())
            {
                request.Method = HttpMethod.Post;
                request.RequestUri = new Uri(requestUri);
                request.Content = new StringContent(requestBody, Encoding.UTF8, "application/json");
                request.Headers.Add("Ocp-Apim-Subscription-Key", _cognitiveServicesKey);
                request.Headers.Add("Ocp-Apim-Subscription-Region", _cognitiveServicesZone);

                var response = await client.SendAsync(request).ConfigureAwait(false);
                var result = await response.Content.ReadAsStringAsync();
                var deserializedOutput = JsonConvert.DeserializeObject<TranslationResult[]>(result);

                return deserializedOutput;
            }
        }
    }
}

The code is simple, I’m just adding a caching layer on top to avoid repeated calls to the API.

You can check the full parameters list in the official documentation, but let me just explain the ones I used:

  • api-version (required): Version of the API requested by the client. Value must be 3.0.
  • to (required): Specifies the language of the output text. The target language must be one of the supported languages included in the translation scope.
  • from (optional): Specifies the language of the input text. Find which languages are available to translate from by looking up supported languages using the translation scope. If the from parameter is not specified, automatic language detection is applied to determine the source language.
  • textType (optional): Defines whether the text being translated is plain text or HTML text. Any HTML needs to be a well-formed, complete element. Possible values are: plain (default) or html. In this case, I’m passing the HTML when is translating from a Rich Text field.

We need also to create the models where the data is parsed into (TranslationResult), I’m not adding the code here to make it simple, but you can check the source code for full details.

TranslationExtensions.cs

using System.Linq;
using System.Threading.Tasks;
using Sitecore.Cognitive.Translator.PSE.Services;
using Microsoft.Extensions.DependencyInjection;
using Sitecore.DependencyInjection;

namespace Sitecore.Cognitive.Translator.PSE.Extensions
{
    public class TranslationExtensions
    {
        private readonly ITranslatorService _translatorService;

        public TranslationExtensions(ITranslatorService translatorServices)
        {
            _translatorService = translatorServices;
        }

        public TranslationExtensions()
        {
            _translatorService = ServiceLocator.ServiceProvider.GetService<ITranslatorService>();
        }

        public async Task<string> TranslateText(string input, string fromLang, string destLang, string textType)
        {
            var res = await _translatorService.GetTranslatation(input, fromLang, destLang, textType);

            if (res != null && res.Any() && res[0].Translations.Any())
            {
                return res[0].Translations[0].Text;
            }

            return string.Empty;
        }
    }
}

Sitecore.Cognitive.Translator.PSE.config

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <settings>
      <setting name="Sitecore.Cognitive.Translator.PSE.TranslateService.ApiKey" value="{YOUR_APP_KEY}" />
      <setting name="Sitecore.Cognitive.Translator.PSE.TranslateService.ApiUrl" value="https://api.cognitive.microsofttranslator.com/" />
      <setting name="Sitecore.Cognitive.Translator.PSE.TranslateService.ApiZone" value="{YOUR_APP_ZONE}" />
      <setting name="Sitecore.Cognitive.Translator.PSE.TranslateService.CacheSize" value="10MB" />
    </settings>
    <services>
      <configurator type="Sitecore.Cognitive.Translator.PSE.DI.RegisterContainer, Sitecore.Cognitive.Translator.PSE" />
    </services>
    <events>
      <event name="publish:end:remote">
        <handler type="Sitecore.Cognitive.Translator.PSE.Caching.CacheManager, Sitecore.Cognitive.Translator.PSE" method="ClearCache" />
      </event>
      <event name="customCache:rebuild:remote">
        <handler type="Sitecore.Cognitive.Translator.PSE.Caching.CacheManager, Sitecore.Cognitive.Translator.PSE" method="ClearCache" />
      </event>
    </events>
  </sitecore>
</configuration>

Powershell Scripts

We need basically one main script to be added in the context menu (Add Language Version and Translate) and then few functions that has been written in this way to make it more readable and modular.

Add Language Version and Translate

Import-Function GetLanguages
Import-Function GetItems
Import-Function ConfirmationMessage
Import-Function Translate
Import-Function GetUserOptions
Import-Function GetUserFieldsToTranslate
Import-Function ConfirmationMessage

# Global variables
$location = get-location
$currentLanguage = [Sitecore.Context]::Language.Name
$langOptions = @{}
$destinationLanguages = @{}
$options = @{}

# Variables from user input - Custom Object
$userOptions = [PSCustomObject]@{
    'FromLanguage'   = $currentLanguage
    'ToLanguages' = @()
    'IncludeSubitems' = $false
    'IncludeDatasources' = $false
    'IfExists' = "Skip"
    'FieldsToTranslate' = @()
}

# Get language options
GetLanguages $langOptions $destinationLanguages

# Ask user for options
$result = GetUserOptions $currentLanguage $langOptions $destinationLanguages $userOptions
if($result -ne "ok") {
    Write-Host "Canceling"
    Exit
}

# Get all items 
$items = @()
$items = GetItems $location $userOptions.IncludeSubitems $userOptions.IncludeDatasources

# Ask user for fields to translate
$dialogResult = GetUserFieldsToTranslate $items $options $userOptions
if($dialogResult -ne "OK") {
    Write-Host "Canceling"
    Exit
}

# Ask user for confirmation
$proceed = ConfirmationMessage $items.Count $options $userOptions
if ($proceed -ne 'yes') {
    Write-Host "Canceling"
    Exit
}

# Call the translator service 
Translate $items $userOptions

GetLanguages

function GetLanguages {
    [CmdletBinding()]
    param($langOptions, $destinationOptions)
	
	$user = Get-User -Current
	$languages = Get-ChildItem "master:\sitecore\system\Languages"
    $currentLanguage = [Sitecore.Context]::Language.Name
	
	# Get list of languages with writting rights and remove the origin language
    foreach ($lang in $languages) {
        $langOptions[$lang.Name] = $lang.Name    
        if (Test-ItemAcl -Identity $user -Path $lang.Paths.Path -AccessRight language:write) {
            $destinationOptions[$lang.Name] = $lang.Name
        }
    }
    
    $destinationOptions.Remove($currentLanguage)
}

GetUserOptions

function GetUserOptions {
    [CmdletBinding()]
    param($currentLanguage, $langOptions, $destinationLanguages, [PSCustomObject]$userOptions)
     
    # Version overwritting options
    $ifExistsOpts = @{};
    $ifExistsOpts["Append"]    = "Append";
    $ifExistsOpts["Skip"]      = "Skip";
    $ifExistsOpts["Overwrite"] = "OverwriteLatest";

    $result = Read-Variable -Parameters `
        @{ Name = "fLang"; Value=$currentLanguage; Title="From Language"; Options=$langOptions; },
        @{ Name = "tLang"; Title="Destination Languages"; Options=$destinationLanguages; Editor="checklist"; },
        @{ Name = "iSubitems"; Value=$false; Title="Include Subitems"; Columns = 4;},
        @{ Name = "iDatasources"; Value=$false; Title="Include Datasources"; Columns = 4 },
        @{ Name = "iExist"; Value="Skip"; Title="If Language Version Exists"; Options=$ifExistsOpts; Tooltip="Append: Create new language version and translate content.<br>" `
                  + "Skip: skip it if the target has a language version.<br>Overwrite Latest: overwrite latest language version with translated content."; } `
        -Description "Select a the from and target languages with options on how to perform the translation" `
        -Title "Add Language and Translate" -Width 650 -Height 660 -OkButtonName "Proceed" -CancelButtonName "Cancel" -ShowHints
    
    $userOptions.FromLanguage = $fLang
    $userOptions.ToLanguages += $tLang
    $userOptions.IncludeSubitems = $iSubitems
    $userOptions.IncludeDatasources = $iDatasources
    $userOptions.IfExists = $iExist
    
    return $result
}

GetItems

function GetItems {
    [CmdletBinding()]
    param($location, $includeSubitems, $includeDatasources)
 
    Import-Function GetItemDatasources
    
    $items = @()
    $items += Get-Item $location
 
    # add subitems
    if ($includeSubitems) {
        $items += Get-ChildItem $location -Recurse
    }
     
    # add datasources
    if ($includeDatasources) {
        Foreach($item in $items) {
            $items += GetItemDatasources($item)
        }
    }
     
    # Remove any duplicates, based on ID
    $items = $items | Sort-Object -Property 'ID' -Unique
    
    return $items
}

GetFields

function GetFields {
    [CmdletBinding()]
    param($items, $options)

    Import-Function GetTemplatesFields
    
    Foreach($item in $items) {
        $fields += GetTemplatesFields($item)
    }
    
    # Remove any duplicates, based on ID
    $fields = $fields | Sort-Object -Property 'Name' -Unique
    
    # build the hashtable to show as checklist options
    ForEach ($field in $fields) {
    	$options.add($field.Name, $field.ID.ToString())
    }
    
    return $fields 
}

GetItemDatasources

function GetItemDatasources {
    [CmdletBinding()]
    param([Item]$Item)
 
    return Get-Rendering -Item $item -FinalLayout -Device (Get-LayoutDevice -Default) |
        Where-Object { -not [string]::IsNullOrEmpty($_.Datasource)} |
        ForEach-Object { Get-Item "$($item.Database):" -ID $_.Datasource }
}

GetTemplatesFields

function GetTemplatesFields {
    [CmdletBinding()]
    param([Item]$Item)
	
	$standardTemplate = Get-Item -Path "master:" -ID ([Sitecore.TemplateIDs]::StandardTemplate.ToString())
	$standardTemplateTemplateItem = [Sitecore.Data.Items.TemplateItem]$standardTemplate
	$standardFields = $standardTemplateTemplateItem.OwnFields + $standardTemplateTemplateItem.Fields | Select-Object -ExpandProperty key -Unique
	$itemTemplateTemplateItem = Get-ItemTemplate -Item $Item
	$itemTemplateFields = $itemTemplateTemplateItem.OwnFields + $itemTemplateTemplateItem.Fields
	$filterFields = $itemTemplateFields | Where-Object { $standardFields -notcontains $_.Name } | Sort-Object
	
	return $filterFields
}

GetUserFieldsToTranslate

function GetUserFieldsToTranslate {
    [CmdletBinding()]
    param($items, $options, [PSCustomObject]$userOptions)
    Import-Function GetFields
    
    # Get all fields from items
    $fields = @()
    $fields = GetFields $items $options
    
    # Promt the user for selecting the fields for translation
    $dialogParams = @{
        Title = "Fields selector"
        Description = "Select the fields you want to translate"
        OkButtonName = "OK"
        CancelButtonName = "Cancel"
        ShowHints = $true
        Width = 600
        Height = 800
        Parameters = @(
            @{
                Name = "fieldsIdToTranslate"
                Title = "Checklist Selector"
                Editor = "check"
                Options = $options
                Tooltip = "Select one or more fields"
            }
        )
    }
    
    $dialogResult = Read-Variable @dialogParams
    $userOptions.FieldsToTranslate = $fieldsIdToTranslate
    
    return $dialogResult
}

ConfirmationMessage

function ConfirmationMessage {
    [CmdletBinding()]
    param($itemsCount, $options, [PSCustomObject]$userOptions)
    
    $fieldsToUpdate = ""
    $opt = @()
    
    ForEach($ft in $userOptions.FieldsToTranslate) {
        $opt = $options.GetEnumerator() | ? { $_.Value -eq $ft }
        $fieldsToUpdate += "$($opt.Key), "
    }
    
    $fieldsToUpdate = $fieldsToUpdate.Substring(0,$fieldsToUpdate.Length-2)
     
    $message = "Updating <span style='font-weight: bold'>$itemsCount item(s)</span>!<br>"
    $message += "<br><table>"
    $message += "<tr><td style='width: 300px'>Origin Language:</td><td style='width: 450px'>$($userOptions.FromLanguage)</td></tr>"
    $message += "<tr><td style='width: 300px'>Destination Languages:</td><td style='width: 450px'>$($userOptions.ToLanguages)</td></tr>"
    $message += "<tr><td style='width: 300px'>Include Subitems:</td><td style='width: 450px'>$($userOptions.IncludeSubitems)</td></tr>"
    $message += "<tr><td style='width: 300px'>Include Datasources:</td><td style='width: 450px'>$($userOptions.IncludeDatasources)</td></tr>"
    $message += "<tr><td style='width: 300px'>Copy Method:</td><td style='width: 450px'>$($userOptions.IfExists)</td></tr>"
    $message += "<tr><td style='width: 300px'>Fields to Translate:</td><td style='width: 450px'>$($fieldsToUpdate)</td></tr>"
    $message += "</td></tr></table>"
     
    return Show-Confirm -Title $message
}

Translate

function Translate {
    [CmdletBinding()]
    param($items, [PSCustomObject]$userOptions)
    
    Write-Host "Proceeding with execution..."
    
    # Call the translator service
    $translatorService = New-Object Sitecore.Cognitive.Translator.PSE.Extensions.TranslationExtensions
    
    $items | ForEach-Object {
    	$currentItem = $_
    	foreach($lang in $userOptions.ToLanguages) {
    		Add-ItemLanguage $_ -Language $userOptions.FromLanguage -TargetLanguage $lang -IfExist $userOptions.IfExists
    		
    		Write-Host "Item : '$($currentItem.Name)' created in language '$lang'"
    		
    		Get-ItemField -Item $_ -Language $lang -ReturnType Field -Name "*" | ForEach-Object{ 
    		    # Only look within Single-line and Rich Text fields that has been choosen in the dialog box
                if(($_.Type -eq "Single-Line Text" -or $_.Type -eq "Rich Text" -or $_.Type -eq "Multiline Text") -and $userOptions.FieldsToTranslate.Contains($_.ID.ToString())) {
                    if (-not ([string]::IsNullOrEmpty($_))) {
                        # Get the item in the target created language
                        $langItem = Get-Item -Path "master:" -ID $currentItem.ID -Language $lang
        				
        				# Get the translated content from the service
        				$translated = $translatorService.TranslateText($currentItem[$_.Name], $userOptions.FromLanguage, $lang, $_.Type)
        				
        				# edit the item with the translated content
        				$langItem.Editing.BeginEdit()
        				$langItem[$_.Name] = $translated.Result
        				$langItem.Editing.EndEdit()
        				
        				Write-Host "Field : '$_' translated from '$($userOptions.FromLanguage)'" $currentItem[$_.Name] " to : '$lang'" $translated.Result
                    }
                }
    	    }
    	}	 
    }
}

In the Translate function, I’m doing the call to the API (Sitecore.Cognitive.Translator.PSE.Extensions.TranslationExtensions).

That’s very much it, now is time to test it! If everything went well, you will be able to add language versions to your items with also translated content from Azure Cognitive Translation.

Let’s see this in action!

For the purpose of this demo, I’ve created a simple content tree with 3 levels, the items has some content in english (plain and HTML) and I’ll be using the tool to create the Spanish-Argentina and French-France versions + translated content.

1- Click on the Home item and choose the Add Language Version and Translate option from the scripts section.

2- Choose the options, in this case I want to translate from the default ‘en‘ language to both ‘es-AR‘ and ‘fr-FR‘. Also I want to include the subitems, but as for this test the items doesn’t have a presentation nor datasources, I’m keeping this disabled. No versions in the target language exist for those items, so I’m keeping the “Skip” option.

3- Click on proceed and choose the fields you want to translate:

I’m selecting all fields, as you can check in the SPE code, I’m removing the standard fields from the items to be translated, normally you don’t want that and it will overpopulate the fields list.

4- Click OK, double check the data entered and click the OK button for making the magic to happen:

5- Click on the View script results link to check the output logs:

6- Check that the items have been created in the desired languages and the contents are already translated. Review them, publish and have a cup of coffee :).

fr-FR items version:

es-AR items version:

Voila! After few clicks you have your content items created in the language version with the content translated, I hope you like it us much as I do.

Find the source code in GitHub, download the Sitecore package here or get the asset image from Docker Hub.

Thanks for reading!

Sitecore media optimization with Azure Functions + Blob Storage + Magick.NET

In my previous post, I’ve explained how to configure the Blob Storage Module on a Sitecore 9.3+ instance. The following post assumes you are already familiar with it and you’ve your Sitecore instance making use of the Azure blob storage provider.

In this post I’ll show you how we can make use of Azure Functions (blob trigger) to optimize (compress) images on the fly, when those are uploaded to the media library, in order to gain performance and with a serverless approach.

Media Compression Flow

About Azure Functions and Blob Trigger

Azure Functions is an event driven, compute-on-demand experience that extends the existing Azure application platform with capabilities to implement code triggered by events occurring in Azure or third party service as well as on-premises systems. Azure Functions allows developers to take action by connecting to data sources or messaging solutions thus making it easy to process and react to events. Developers can leverage Azure Functions to build HTTP-based API endpoints accessible by a wide range of applications, mobile and IoT devices. Azure Functions is scale-based and on-demand, so you pay only for the resources you consume. For more info please refer to the official MS documentation.

Azure Functions

Azure Functions integrates with Azure Storage via triggers and bindings. Integrating with Blob storage allows you to build functions that react to changes in blob data as well as read and write values.

Creating the Azure Function

For building the blob storage trigger function I’ll be using Visual Code, so first of all make sure you have the Azure Functions plugin for Visual Code, you can get it from the marketplace or from the extensions menu, also from the link: vscode:extension/ms-azuretools.vscode-azurefunctions.

Install the extension for Azure Functions
Azure Functions Plugin

Before proceeding, make sure you are logged into your Azure subscription. >az login.

  1. Create an Azure Functions project: Click on the add function icon and then select the blob trigger option, give a name to the function.

2. Choose the Blob Storage Account you are using in your Sitecore instance (myblobtestazure_STORAGE in my case).

3. Choose your blob container path (blobcontainer/{same})

4. The basics are now created and we can start working on our implementation.

Default function class

Generated project files

The project template creates a project in your chosen language and installs required dependencies. For any language, the new project has these files:

  • host.json: Lets you configure the Functions host. These settings apply when you’re running functions locally and when you’re running them in Azure. For more information, see host.json reference.
  • local.settings.json: Maintains settings used when you’re running functions locally. These settings are used only when you’re running functions locally. For more information, see Local settings file.

Edit the local.settgins.json file to add the connection string of your blob storage:

local.settings.json

The function implementation

using System.IO;
using Microsoft.Azure.WebJobs;
using Microsoft.Extensions.Logging;
using ImageMagick;
using Microsoft.WindowsAzure.Storage.Blob;

namespace SitecoreImageCompressor
{
    public static class CompressBlob
    {
        [FunctionName("CompressBlob")]
        public static async void Run([BlobTrigger("blobcontainer/{name}", Connection = "myblobtestazure_STORAGE")] CloudBlockBlob inputBlob, ILogger log)
        {
            log.LogInformation($"C# Blob trigger function Processed blob\n Name:{inputBlob.Name} \n Size: {inputBlob.Properties.Length} Bytes");

            if (inputBlob.Metadata.ContainsKey("Status") && inputBlob.Metadata["Status"] == "Processed")
            {
                log.LogInformation($"blob: {inputBlob.Name} has already been processed");
            }
            else
            {
                using (var memoryStream = new MemoryStream())
                {
                    await inputBlob.DownloadToStreamAsync(memoryStream);
                    memoryStream.Position = 0;

                    var before = memoryStream.Length;
                    var optimizer = new ImageOptimizer { OptimalCompression = true, IgnoreUnsupportedFormats = true };

                    if (optimizer.IsSupported(memoryStream))
                    {
                        var compressionResult = optimizer.Compress(memoryStream);

                        if (compressionResult)
                        {
                            var after = memoryStream.Length;
                            var gain = 100 - (float)(after * 100) / before;

                            log.LogInformation($"Optimized {inputBlob.Name} - from: {before} to: {after} Bytes. Optimized {gain}%");

                            await inputBlob.UploadFromStreamAsync(memoryStream);
                        }
                        else
                        {
                            log.LogInformation($"Image {inputBlob.Name} - compression failed...");
                        }
                    }
                    else
                    {
                        var info = MagickNET.GetFormatInformation(new MagickImageInfo(memoryStream).Format);

                        log.LogInformation($"Image {inputBlob.Name} - the format is not supported. Compression skipped - {info.Format}");
                    }
                }

                inputBlob.Metadata.Add("Status", "Processed");
                
                await inputBlob.SetMetadataAsync();
            }
        }
    }
}

As you can see, I’m creating and async task that will be triggered as soon as a new blob is added to the blob storage. Since we’re compressing and then uploading the modified image, we’ve to make sure the function is not triggered multiple times. For avoiding that, I’m also updating the image metadata with a “Status = Processed“.

The next step is to get the image from the CloudBlockBlob and then compress using the Magick.NET library. Please note that this library also provides a LosslessCompress method, for this implementation I choose to go with the full compression. Feel free to update and compare the results.

Nuget references

So, in order to make it working we need to install the required dependencies. Please run the following commands to install the Nuget packages:

  • dotnet add package Azure.Storage.Blobs –version 12.8.0
  • dotnet add package Magick.NET-Q16-AnyCPU –version 7.23.2
  • dotnet add package Microsoft.Azure.WebJobs.Extensions.Storage –version 3.0.10
  • dotnet add package Microsoft.Azure.WebJobs.Host.Storage –version 4.0.1
  • dotnet add package Microsoft.NET.Sdk.Functions –version 1.0.38

Test and deploy

Now we have everything in place. Let’s press F5 and see if the function is compiling

Terminal output

We are now ready to deploy to Azure and test the blob trigger! Click on the up arrow in order to deploy to Azure, choose your subscription and go!

Azure publish

Check the progress in the terminal and output window:

Testing the trigger

Now we can go to the Azure portal, go to the Azure function and double check that everything is there as expected:

Azure function from the portal

Go to the “Monitor” and click on “Logs” so we can have a look at the live stream when uploading an image to the blob storage. Now in your Sitecore instance, go to the Media Library and upload an image, this will upload the blob to the Azure Storage and the trigger will take place and compress the image.

Media Library Upload
Azure functions logs

As we can see in the logs the image got compressed, gaining almost 15%:

2021-02-23T10:21:36.894 [Information] Optimized 6bdf3e56-c6fc-488b-a7bb-eee64ce04343 – from: 81147 to: 69158 Bytes. Optimized 14.774422%

Azure Blob Storage – With the trigger enabled
Azure Blob Storage – With the trigger disabled

Let’s check the browser for the final results

Without the trigger: the image size is 81147 bytes.

With the trigger: the image size is 69158 bytes.

I hope you find this useful, you can also get the full implementation from GitHub.

Thanks for reading!

How to enable Azure Blob Storage on Sitecore 9.3+

In this post I’m explaining how to switch the blob storage provider to make use of Azure Blob Storage. Before Sitecore 9.3, we could store the blobs on the DB or filesystem, Azure Blob Storage was not supported out of the box and even tough it was possible, it required some customizations to make it working, nowadays, since Sitecore 9.3 a module has been released and is very straightforward to setup, as you will see in this post.

By doing this we can significantly reduce costs and improve performance as the DB size won’t increase that much due to the media library items.

Resultado de imagen de azure blob storage

Introduction to Azure Blob storage

Azure Blob storage is Microsoft’s object storage solution for the cloud. Blob storage is optimized for storing massive amounts of unstructured data. Unstructured data is data that doesn’t adhere to a particular data model or definition, such as text or binary data.

Blob storage is designed for:

  • Serving images or documents directly to a browser.
  • Storing files for distributed access.
  • Streaming video and audio.
  • Writing to log files.
  • Storing data for backup and restore, disaster recovery, and archiving.
  • Storing data for analysis by an on-premises or Azure-hosted service.

Users or client applications can access objects in Blob storage via HTTP/HTTPS, from anywhere in the world. Objects in Blob storage are accessible via the Azure Storage REST APIAzure PowerShellAzure CLI, or an Azure Storage client library.

For more info please refer here and also you can find some good documentation here.

Creating your blob storage resource

Azure Storage Account

Create the resource by following the wizard and then check the “Access Keys” section, you’ll need the “Connection string” later.

Connection String and keys

Configuring your Sitecore instance

There are basically three main option to install the blob storage module into your instance:

  1. Install the Azure Blob Storage module in Sitecore PaaS.
    1. Use the Sitecore Azure Toolkit:
      1. Use a new Sitecore installation with Sitecore Azure Toolkit
      2. Use an existing Sitecore installation with Sitecore Azure Toolkit
    2. Use Sitecore in the Azure Marketplace (for new Sitecore installations only)
  2. Install the Azure Blob Storage module on an on-premise Sitecore instance.
  3. Manually install the Azure Blob Storage module in PaaS or on-premise.

This time I’ll be focusing in the last option, manually installing the module, doesn’t matter if it’s a PaaS or on-premise approach.

Manual installations steps

  1. Download the Azure Blob Storage module WDP from the Sitecore Downloads page.
  2. Extract (unzip) the WDP.
  3. Copy the contents of the bin folder of the WDP into the Sitecore web application bin folder.
  4. Copy the contents of the App_Config folder of the WDP into the Sitecore web application App_Config folder.
  5. Copy the contents of the App_Data folder of the WDP into the Sitecore web application App_Data folder.
  6. Add the following connection string to the App_Config\ConnectionStrings.config file of the Sitecore web application.
 <add name="azureblob" connectionString="DefaultEndpointsProtocol=https;AccountName=myblobtestazure;AccountKey={KEY};EndpointSuffix=core.windows.net"/>

7. In the \App_Config\Modules\Sitecore.AzureBlobStorage\Sitecore.AzureBlobStorage.config file, ensure that <param name="blobcontainer"> is the name you gave to the container after creating the resource.

Let’s test it!

If everything went well, then we can just test it by uploading a media item to the Sitecore media library

Let’s have a look now at the Storage Explorer in the Azure portal

Here we go, the image is now uploaded into the Azure blob storage, meaning the config is fine and working as expected.