Sitecore JSS – NEXT.js – Exploring the Incremental Site Regeneration (ISR).

Next.js allows you to create or update static pages after you’ve built your site. Incremental Static Regeneration (ISR) enables developers and content editors to use static-generation on a per-page basis, without needing to rebuild the entire site. With ISR, you can retain the benefits of static while scaling to millions of pages.

Static pages can be generated at runtime (on-demand) instead of at build-time with ISR. Using analytics, A/B testing, or other metrics, you are equipped with the flexibility to make your own tradeoff on build times.

Consider an e-commerce store with 100,000 products. At a realistic 50ms to statically generate each product page, the build would take almost 2 hours without ISR. With ISR, we can choose from:

Faster Builds → Generate the most popular 1,000 products at build-time. Requests made to other products will be a cache miss and statically generate on-demand: 1-minute builds.

Higher Cache Hit Rate → Generate 10,000 products at build-time, ensuring more products are cached ahead of a user’s request: 8-minute builds.

Exploring ISR

In my previous post I’ve created a JSS-Next.js app that we deployed to Vercel. I also created a Web Hook to trigger a full rebuild in Vercel (SSG). Now, I’ll explain how the ISR works in this same app.

Fetching Data and Generating Paths

Data:

ISR uses the same Next.js API to generate static pages: getStaticProps.
By specifying revalidate: 5, we inform Next.js to use ISR to update this page after it’s generated.

Check the src/pages/[[…path]].tsx file and the getStaticProps function:

Paths:

Next.js defines which pages to generate at build-time based on the paths returned by
getStaticPaths. For example, you can generate the most popular 1,000 products at build-time by returning the paths for the top 1,000 product IDs in getStaticPaths.

With this configuration, I’m telling Next.js to enable ISR and to revalidate every 5 sec. After this time period, the first user making the request will receive the old static version of the page and trigger the revalidation behind the scenes.

The Flow

  1. Next.js can define a revalidation time per-page (e.g. 5 seconds).
  2. The initial request to the page will show the cached page.
  3. The data for the page is updated in the CMS.
  4. Any requests to the page after the initial request and before the 5 seconds window will show the cached (hit) page.
  5. After the 5 second window, the next request will still show the cached (stale) page. Next.js triggers a regeneration of the page in the background.
  6. Once the page has been successfully generated, Next.js will invalidate the cache and show the updated product page. If the background regeneration fails, the old page remains unaltered.

Page Routing

Here’s a high-level overview of the routing process:

In the diagram above, you can see how the Next.js route is applied to Sitecore JSS.

The [[…path]].tsx Next.js route will catch any path and pass this information along to getStaticProps or getServerSideProps on the context object. The Page Props Factory uses the path information to construct a normalized Sitecore item path. It then makes a request to the Sitecore Layout Service REST API or Sitecore GraphQL Edge schema to fetch layout data for the item.

Demo!

So, back to our previously deploied app in Vercel, login to Sitecore Content Editor and make a change on a field. I’m updating the heading field (/sitecore/content/sitecoreverceldemo/home/Page Components/home-jss-main-ContentBlock-1) by adding “ISR Rocks!”. We save the item and refresh the page deployed on Vercel. (Don’t publish! this will trigger the webhook that is defined in the publish:end event).

After refreshing the page, I can still see the old version:

But, if I keep checking what is going on in the ngrok, I can see the requests made to the layout service:

So, after refreshing again the page, I can see the changes there!

So, it got updated without the need of rebuilding and regeneating the whole site.

That’s it! I hope this post helps to understand how the ISR works and how to start with it on your Sitecore JSS implementation.

Thanks for reading and stay tuned for more Sitecore stuff!

Deploying a Sitecore JSS-Next.js App with SSG & ISR to Vercel (from zero to live)

In this post I’ll share the steps to get our Next.js Sitecore App deployed into Vercel on some simple steps. Vercel is the creator of Next.js and now also a Sitecore partner. To avoid a huge and extensive post, I won’t be writting about Next.js, Vercel, JSS, etc. instead, please find some useful links with references to all those at the end of the blog post.

Getting the JSS app locally

The first step is to have the Sitecore JSS-Next.js app running locally. For simplifying things, we’ll be creating it with the help of JSS CLI. Before start, make sure you’ve Node.js installed locally.

We just get started by running the following command to install the JSS CLI (more info here):

npm install -g @sitecore-jss/sitecore-jss-cli

Now, we can start to play with the CLI, so let’s create the app:

jss create sitecoreverceldemo nextjs

We give it a name (sitecoreverceldemo) and a framework (nextjs).

Connected Mode

The first thing we need to do is to create our API key so our JSS app can communicate to our Sitecore instance. In this demo I’m running a local Sitecore instance, but it could be also a containerized one.

So, for doing that, we login into Sitecore and go to /sitecore/system/Settings/Services/API Keys and we create a new item, we give it a name and copy the ID somewhere, this gonna be our API key moving forward.

Now, we run the following command to start setting up it:

jss setup

We just follow the wizard and set the proper values for our Sitecore instance, API Key, import service URL, etc. If all went well, then you should see something like this:

Note that you’ve to add the recently created hostname to the hosts file in windows and ISS (sitecore.vercel.demo).

We’re now ready to deploy the configs, and right after that, the items, for doing that we simply run the following commands:

jss deploy config
jss deploy items --includeContent --includeDictionary

This will run the import to Sitecore and create the sample items. We can now build the app by running:

jss build

That’s it! We can now start our JSS App in connected mode:

jss start:connected

Code Repository

We now run some Git commands to push our code to GitHub. You can find the one I’m using for this demo here.

Vercel

Now, that we are done with our JSS Next.js app, we can have fun deploying it to Vercel. First good news here, you can go and create your free account for testing purposes 🙂

Another good thing about Vercel is that it connects to GitHub, GitLab and Bitbucket, so it makes things really easy. So, let’s go and import our GitHub repo there:

Let’s click on import and then configure our project. (Note: skip the Teams creation step to avoid having to get a trial account).

BUT as we have our Sitecore instance locally (or running on a container) we’ve to somehow expose our localhost to the internet. For that we can use this amazing tool: Ngrok.

As I’m using the free version, it generates random URLs, but this is enough for our demo. Don’t forget to add those to the IIS binding and hostfile (if you’re running Sitecore locally).

Back to Vercel, we have to setup some environment variables:

SITECORE_API_KEY: The Sitecore API key we created in the previous step.
SITECORE_API_HOST: The URL generated by NGRock.
JSS_EDITING_SECRET: Your secret token. The JSS_EDITING_SECRET is optional for deployments but necessary if you want to use the Experience Editor with your Next.js Vercel deployment.

In next.config.js, replace:

const publicUrl = process.env.PUBLIC_URL;

with the following:

const publicUrl = process.env.VERCEL_URL ? https://${process.env.VERCEL_URL} : process.env.PUBLIC_URL;

So, it takes the URL we defined as environment variable in Vercel.

Now everything is set and ready to be deployed. Let’s get back to Vercel, and deploy!

You’ll need to also update the hostname in sitecore/config/sitecoreverceldemo.config.

If everything was well configured, you should be able to see the requests to the headless services while Next.js is generating the static siteduring the building, something like this:

Et voila! The site is now live!

The publishing webhook

We need now to trigger the deploy if a content gets changed in the CMS. For that, we create a deploy hook in Vercel:

In the settings/Git section, we choose to create a deploy hook, we give it a name and a branch (develop in this case).

Create the hook and copy the URL. Let’s create now a config patch in Sitecore that will trigger it on publush:end

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:role="http://www.sitecore.net/xmlconfig/role/" xmlns:search="http://www.sitecore.net/xmlconfig/search/">
  <sitecore>
    <javaScriptServices>
      <publishWebHooks type="Sitecore.JavaScriptServices.AppServices.WebHooks.WebHooks, Sitecore.JavaScriptServices.AppServices">
        <hooks hint="list:AddWebHook">
          <hook type="Sitecore.JavaScriptServices.AppServices.WebHooks.WebHookDefinition, Sitecore.JavaScriptServices.AppServices">
            <name>Vercel - Publish Hook</name>
            <url>https://api.vercel.com/v1/integrations/deploy/prj_f4NcDuveH3zCP9a6pObWlaApER14/T7ATu61aej</url>
            <method>POST</method>
            <site>sitecoreverceldemo</site>
            <body></body>
          </hook>
        </hooks>
      </publishWebHooks>
    </javaScriptServices>
  </sitecore>
</configuration>
  • url: Required. The URL of the hook to be invoked.
  • method: Optional. The HTTP method for invoking webhook. Possible values are POST or GET. The default method is POST.
  • site: Optional. The sites which should trigger the webhook when published. By default, Sitecore will trigger the webhook for every published item. If you provide the site parameter, the webhook will be invoked if the published item root is an ancestor, descendant, or equal to the configured site’s root item.

Let’s test it, make a quick change in Sitecore and publish the item (heading field):

After publishing, we can see that the deploy hook got triggered in Vercel:

Refresh the site, and we can see our changes there:

In my next post I’ll explain a bit how the ISR works as it deserves a specific writting about it.

That’s it! As you can see the steps to setup your CI/CD with Vercel is quite straightforward. I hope you find this post useful and helps you with the first steps getting into Next.js and Vercel. Stay tuned for more Sitecore stuff!

References:

Sitecore Moosend – Part III: Campaigns, Automation, Templates, Designer and Subscription Forms

In the previous posts (Part I and Part II) we’ve explored the Mailing Lists and Segmentation, Custom Fields, a bit about Automation and both the front end and API integration approaches.

Let’s go deeper into the other features and functionalities that Moosend provides out of the box and some quick examples on how to make use of those in your website.

Subscription Forms

Creating a form to, for example, get users subscribing to your newsletter is easy and straightforward. Just go to the Lead Generation section and then clikc on the Subscription Forms tab. Click on create new subscription form and you will see the different options Moosend proposes:

I’ll choose and create a Modal Pop-up, that will do the same than we have done in the previous posts via the API, adding a user to a mailing list with the birthday as optional field, so then we can apply our segmentation based on that.

After we choose a name and we go to the next step, you will see the option to make use of the designer.

The Designer

Moosend has a really cool and user friendly interface to build your email templates and forms. It already comes with a lot of different templates ready to be used making the user life’s easier. Also the editor, with drag & drop functionalities and grids helps to edit those or to create them from scratch.

We can then preview the form and then, if we’re good with it, use it.

In the editor, we go to the form settings and we assign our mailing list and also include the custom field (Date of Birth)

We enable all fields and keep only Email and Name as mandatory, the Date of Birth is optional.

Our form is ready, we clock on save and continue to get back to the Subscription Forms wizard.

In the “Visibility Settings” you will find a lot of different options to handle how and when to show up the modal in your webiste. You can also define and use rules for showing/hidding it.

The form is now ready to be published. Moosend offers different alternatives, like publishing straight to your configured site, embed a code or link to it.

After publishing, we can see and test our form, as you can see I’m just publishing it to my page:

I’ve subscribed two more users through the form ([email protected] and [email protected]). Let’s go and check how the mailing list looks now:

The new users have been added, please note the source is now = Form. If we check the segmentation, you will see that those users doesn’t belong to the “API Subscribers” as has been added though the newsletter form.

Campaigns

Let’s now create a campaign that we will use to put all things together, for doing that, we go to the Campaigns section and fill out the required data:

After we complete this section, we can assign this campaign to any of out Mailing Lists or Segments, for this example I’m assigning the “API Subscriber” segment from our list “My Testing List“.

The next step is to select an HTML or just text version, we will be using the HTML version for this example.

Same as when creating a Form, you can make use of the builtin templates or use de editor to edit it or start one from scratch.

We can now test the campaign, if we are good with it, we are then ready to enable and schedule it.

That’s very much it, we have now our campaign ready and sending emails to our segmentated mailing list.

If everything went well, you should receive a notification like this:

I hope you find this Moosend post series interesting and useful, it was just a quick and simplistic example just to give an overview on the different features and options but I hope you get the main idea and ways of working with Moosend, combining all those tools and featrures you can enrich your marketing and get it to the next level.

Thanks for reading and stay tuned for more Sitecore acquisition products overviews!

Sitecore Moosend – Part II: Mailing Lists, Custom Fields and API Integration

In my previous post I’ve shared a quick overview on Moosend and some of the main features. I advise you to take a look at the previous one before proceeding with this reading.

Today we will explore a bit more in depth the mailing lists, custom fields and also the API implementation approach.

Mailing Lists and Segments

In the previous post we created a Mailing List and added subscribers using the front end approach, applying Segmentation to it, we can improve the efficency of our marketing campaign by targeting the audience based on the data gathered from the users (custom fields) and the events recorded by Moosend.

Custom Fields

We can define in this section custom fields that we then can use for gathering data from the user, on top of the default ones (Name, Email and Mobile). We can use those afterwards for automation, segmentation, etc.

Let’s create a new custom field (Date of Birth) and make it optional:

Our custom field is now created and we can use it for our example. Check the generated tag: “recipient:Date of Birth“: you can make use of this token for pesonalize your campaigns.

Segments

Let’s for example take our previously created list “My Testing List” and create a new segmentation based on the Subscription Method = API Integration AND Date of Birth field < 01-01-2010“.

We give a name and then add a criteria, so here we’re creating a segmentation where we fetch “all contacts that subscribed through the API integration method and provided a Date of Birth before 01-01-2010“.

API Integration

As mentioned, this time we’ll be doing the integration it with the API approach. Moosend provides an API wrapper (Javascript or C# .NET) that makes working with it really straightforward, you can find the Nuget package here.

First of all, go to the setting section and then click in API key, copy it and save for later:

Now we can create our service class on .NET Core that will interact with the Moosend API:

MoosendService.cs

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Moosend.Api.Client.Common.Models;
using MyApp.WebApi.Configuration;

namespace MyApp.WebApi.Services
{
    public class MoosendService : IMoosendService
    {
        public MoosendService(IOptions<MoosendSettings> settings)
        {
            MoosendSettings = settings.Value;
        }

        private MoosendSettings MoosendSettings { get; }

        public async Task<Moosend.Api.Client.Common.Models.Subscriber> AddSubscriberAsync(string name, string email,
            DateTime dob)
        {
            var mailingListId = new Guid(MoosendSettings.MailingListID);
            var apiKey = new Guid(MoosendSettings.ApiKey);
            var apiClient = new Moosend.Api.Client.MoosendApiClient(apiKey);
            var customFields = new Dictionary<string, string> {{"Date of Birth", dob.ToLongDateString()}};
            var member = new SubscriberParams()
            {
                Email = email,
                Name = name,
                CustomFields = customFields
            };

            return await apiClient.SubscribeMemberAsync(mailingListId, member);
        }
    }
}

MoosendController.cs

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using MyApp.WebApi.Services;

namespace MyApp.WebApi.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class MoosendController : ControllerBase
    {
        public MoosendController(IMoosendService moosendService)
        {
            MoosendService = moosendService;
        }

        private IMoosendService MoosendService { get; }

        [HttpPost("AddSubscriber/{name}/{email}/{dob}")]
        public async Task<Moosend.Api.Client.Common.Models.Subscriber> AddSubscriber(string name, string email,
            string dob)
        {
            return await MoosendService.AddSubscriberAsync(name, email, Convert.ToDateTime(dob));
        }
    }
}

Let’s now test it on Swaggwer, I’ll create 2 users with a birthdate before 01-01-2010 and one after this date, so we can test the segmentation properly:

Check now the Mailing List:

We can see our 3 members being added through the API, let’s take a look now at the segmentation:

We can see the two subscribers matching the segmentation criteria. In the next post I’ll be showing how to make use of the previously created mailing list, custom fields and segments with the campaigns and automation, we will also have a quick look at the template designer.

I hope you find it useful and keep tuned for more Moosend posts!

A first look at Moosend – One of the latest Sitecore acquisitions.

Moosend is a SaaS all-in-one email marketing tool that not only provides email marketing features but also advanced marketing automation, reporting, landing pages, tracking, newsletters, and subscription forms.

We can think about Moosend as the SaaS version of Sitecore’s Email Experience Manager (EXM) platform.

As this is one of the latest Sitecore acquisitions, in this post I’ll do a quick overview and first steps to get up to speed with it.

Moosend’s main features include:

  • API First Integration
  • Personalization, Segmentation and A/B Testing
  • Marketing Automation
  • Analytics and Reporting
  • Third Parties Integration
  • Landing Pages, Emails and Forms deigner with predefined templates

Dashboard

This is how the Moosend dashboard looks like:

First Steps

A cool thing about Moosend is that it allows you to create a free account, with already a lot of features to test. So, let’s go and create our first account.

Setting up the sender

Before starting to play with Moosend, we have to get our sender configured, for doing that, we go to the settings -> senders option and then “Add new sender”. We give a name (that will be used as the sender name while sending emails) and an email account.

The next step is to setup the DNS records (DKIM and SPF).

You would need to ask your IT department if you’re setting up your enterprise email account, for this demo I’m just setting up my personal server so I’ve access rights to do it myself.

If everything went good, then you should be able to verify the DNS records and get ready to start sending emails.

Configure your website

In order to enable the Moosend’s tracker on your website, go to settings -> websites -> Add website.

Enter the domain, and then you have several options to connect with it.

A website ID will be created, then you will have some connectors to use or just go with the custom installation that is quite a simple HTML code to be added to the head section of your layout.

After adding this snippet, your website can start making use of the tracker, meaning you can start easily sending events from the front end, for example for tracking, to trigger automation, or to subscribe a user to a mailing list.

Moosend gives two different approaches to facilitate the integration with your website, as I explained above, through the tracker or through calls to the API.

Let’s first have a quick look at the tracker.

You can identify the user by using the following event:

mootrack(‘identify’, ‘[email protected]’)

Then we can start, for example, to send a custom event that I will be using for triggering an automation.

Note: Moosend provides some examples on the website integration section: settings -> Websites -> MySite -> Action Tracking Configuration Examples.

Now, we are ready to play with some custom events, let’s go and see this in action by sending a “MyTestingAction” custom event to Moosend tracker, adding it for example, to a button on our site.

mootrack(‘MyTestingAction’);

Automation

Let’s now go to the Automations tab and create a new automation. You will see that Moosend provides a lot of different OOTB templates, but for this example, I’m just choosing the “Custom automation” option.

The Automations editor is really straightforward and easy to use.

Click on “Select your trigger” and you will see the different options, I selected for this case “When custom event is recorded”.

Then we select the options and we choose the event that we previously defined (MyTestingAction). Bear in mind that for the event to appear in the dropdown has to be fired at least once.

Then we add an action, in this case, I’m adding the user (email) to a mailing list (subscribe).

We can now add the action, I’ll be choosing the”Then subscribe to list” option, for the demo I’ve also created a Mailing List (“My Testing List”), and finally, you can choose to add him as verified or not.

Now the automation is ready, we can enable it and see it in action. The interface is really easy to use and the options are huge.

We check now that after triggering the custom event, the user is being added to the mailing list.

In the next post, I’ll explain how to create a campaign, a quick overview of the editor and the OOTB templates. We will be then adding an extra automation step to send an email to the user subscribed in the previous step. Also, I’ll be focusing and doing the demo with the API approach, I hope you find this interesting, and keep tuned for more Moosend related posts!

Sitecore publishing notifications to MS Teams

This time I’m sharing a simple implementation for sending notifications to a MS Teams channel. This can be useful when you want to keep a group of Sitecore users updated on the publishing operations.

It also useful when publishing large amount of items and the editors won’t keep the Sitecore session, so there is no need to go and check the jobs that are running, why not getting a notification on MS Teams?

Also, the idea behind this post is to quickly show how simple is to build a MS Teams Connector and also, how to perform a custom action in the Sitecore publish:end event.

Sitecore publishing notifications to MS Teams

Creating the MS Teams Connector

The first step is to create a Incoming Webhook connector. Click on the three dots next to your Teams channel and choose Connectors:

Give it a name, upload an image and click on create. Save the wehbook url, you will need it later.

The event handler

We just need to create a custom event hanlder to be triggered in the publish:end event.

<events>
  <event name="publish:end">
    <handler type="MSTeamsPublishing.Events.Notification, MSTeamsPublishing" method="SendNotification"/>
  </event>
</events>

Notification.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using MessageCardModel;
using MessageCardModel.Actions;
using MessageCardModel.Actions.OpenUri;
using Microsoft.Extensions.DependencyInjection;
using MSTeamsPublishing.Services;
using Sitecore.DependencyInjection;
using Sitecore.Publishing;
using Sitecore.Sites;

namespace MSTeamsPublishing.Events
{
    public class Notification
    {
        private readonly IItemSiteResolver _siteResolver;
        private readonly IMsTeamsConnectorService _msTeamsConnectorService;

        public Notification()
        {
            _siteResolver = ServiceLocator.ServiceProvider.GetService<IItemSiteResolver>();
            _msTeamsConnectorService = ServiceLocator.ServiceProvider.GetService<IMsTeamsConnectorService>();
        }

        public Notification(IItemSiteResolver siteResolver, IMsTeamsConnectorService msTeamsConnectorService)
        {
            _siteResolver = siteResolver;
            _msTeamsConnectorService = msTeamsConnectorService;
        }

        public void SendNotification(object sender, EventArgs args)
        {
            var sitecoreArgs = args as Sitecore.Events.SitecoreEventArgs;

            if (!(sitecoreArgs?.Parameters[0] is Publisher publisher)) return;

            var rootItem = publisher.Options.RootItem;
            var publishJobs = Sitecore.Jobs.JobManager.GetJobs().Where(x => x.Name.Equals(publisher.GetJobName())).ToList();
            var site = _siteResolver.ResolveSite(rootItem);
            var hostUrl = "https://" + (site != null ? site.HostName : $"{HttpContext.Current?.Request.Url.Scheme}://{HttpContext.Current?.Request.Url.Host}");
            var ItemId = HttpUtility.UrlEncode(rootItem.ID.ToString());

            foreach (var j in publishJobs.Where(p => p.Handle.IsLocal))
            {
                var teamsMessage = new MessageCard();
                var facts = new List<Fact> { new Fact {Name = "User: ", Value = publisher.Options.UserName } };

                foreach (var message in j.Status.Messages)
                {
                    var messageSplit = message.Split(':');
                    var fact = new Fact {Name = $"{messageSplit[0]}: ", Value = messageSplit[1]};
                    facts.Add(fact);
                }

                var section = new Section
                {
                    ActivityTitle = $"{j.Name} Done!",
                    ActivitySubtitle = $"Version: {rootItem.Version}, Language: {rootItem.Language}, Target DB: {publisher.Options.TargetDatabase}. Subitems: {publisher.Options.Deep}",
                    ActivityImage = "https://sitecorecdn.azureedge.net/-/media/sitecoresite/images/global/logo/favicon.png",
                    Facts = facts
                };

                var sitecoreRedirectAction = new OpenUriAction { Type = ActionType.OpenUri, Name = "Go to Sitecore", Targets = new [] { new Target { OS = TargetOs.Default, Uri = $"{hostUrl}/sitecore/shell/sitecore/content/Applications/Content Editor.aspx?id={ItemId}&amp;la={rootItem.Language}&amp;fo={ItemId}" } } };
                var publicRedirectAction = new OpenUriAction { Type = ActionType.OpenUri, Name = "Go to website", Targets = new [] { new Target { OS = TargetOs.Default, Uri = $"{hostUrl}/?sc_itemid={ItemId}&amp;sc_mode=normal&amp;sc_lang={rootItem.Language}" } } };

                teamsMessage.Context = "https://schema.org/extensions";
                teamsMessage.Type = "MessageCard";
                teamsMessage.Summary = "Publish Notification";
                teamsMessage.ThemeColor = "008000";
                teamsMessage.Sections = new [] {section};
                teamsMessage.Actions = new [] {sitecoreRedirectAction, publicRedirectAction};

                _msTeamsConnectorService.ProcessAsync(teamsMessage).ConfigureAwait(false).GetAwaiter().GetResult();
            }
        }
    }
}

The code is very simple, and to avoid creating the models for building the MS Teams Cards, I’m using the MessageCardModel Nuget package. Have a look also at this Message Card Playground tool, you can use to design your cards.

You can find more info about the MS Teams connector wehbook here.

MSTeamsConnectorService.cs

using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using MessageCardModel;
using Sitecore.Configuration;

namespace MSTeamsPublishing.Services
{
    public class MsTeamsConnectorService : IMsTeamsConnectorService
    {
        public async Task ProcessAsync(MessageCard card)
        {
            var requestUri = Settings.GetSetting("MSTeamsPublishing.TeamsWebhookUrl", string.Empty);
            var converted = card.ToJson();

            using (var client = new HttpClient())
            using (var content = new StringContent(converted, Encoding.UTF8, "application/json"))
            using (var response = await client.PostAsync(requestUri, content).ConfigureAwait(false))
            {
                response.EnsureSuccessStatusCode();
            }
        }
    }
}

Config patch

<settings>
    <setting name="MSTeamsPublishing.TeamsWebhookUrl" value="your webhook URL here" />
 </settings>

The only config needed is to put the webhook URL you get in the first step, after creating the connector in Teams.

Let’s test it!

If everything went well, you should be able to get a notification after the publishing is completed.

You can find the whole implementation in GitHub.

Thanks!

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.

Sitecore smart image cropping, tags and alt text with AI: Azure Computer Vision – Part III.

In my previous post I’ve shared the custom image field implementation that makes use of the Azure Computer Vision service in order to crop and generate the thumbnails using AI. Please before proceed with this reading, make sure you already went through the previous posts: Part I and Part II.

Now, I’ll be sharing the last, but not least part of this topic, how to make it working in the front-end side, the media request flow and so on.

Image request flow

Image request flow
The image request flow

So, the request flow is described in the following graph, basically follows the normal Sitecore flow but with the introduction of the Azure Computer Vision and Image Sharp to generate the proper cropping version of the image.

AICroppingProcessor

This custom processor will be overriding the Sitecore OOTB ThumbnailProcessor. It’s basically a copy from the original code with a customization to check the “SmartCropping” parameter from the image request.

using Sitecore.Diagnostics;
using Sitecore.Resources.Media;
using System;
using Microsoft.Extensions.DependencyInjection;
using System.IO;
using System.Linq;
using Sitecore.Computer.Vision.CroppingImageField.Services;
using Sitecore.DependencyInjection;

namespace Sitecore.Computer.Vision.CroppingImageField.Processors
{
    public class AICroppingProcessor
    {
        private static readonly string[] AllowedExtensions = { "bmp", "jpeg", "jpg", "png", "gif" };

        private readonly ICroppingService _croppingService;

        public AICroppingProcessor(ICroppingService croppingService)
        {
            _croppingService = croppingService;
        }

        public AICroppingProcessor()
        {
            _croppingService = ServiceLocator.ServiceProvider.GetService<ICroppingService>();
        }

        public void Process(GetMediaStreamPipelineArgs args)
        {
            Assert.ArgumentNotNull(args, "args");

            var outputStream = args.OutputStream;

            if (outputStream == null)
            {
                return;
            }

            if (!AllowedExtensions.Any(i => i.Equals(args.MediaData.Extension, StringComparison.InvariantCultureIgnoreCase)))
            {
                return;
            }

            var smartCrop = args.Options.CustomOptions[Constants.QueryStringKeys.SmartCropping];

            if (!string.IsNullOrEmpty(smartCrop) && bool.Parse(smartCrop))
            {
                Stream outputStrm;

                outputStrm = Stream.Synchronized(_croppingService.GetCroppedImage(args.Options.Width, args.Options.Height, outputStream.MediaItem));
                args.OutputStream = new MediaStream(outputStrm, args.MediaData.Extension, outputStream.MediaItem);
            }
            else if (args.Options.Thumbnail)
            {
                var transformationOptions = args.Options.GetTransformationOptions();
                var thumbnailStream = args.MediaData.GetThumbnailStream(transformationOptions);

                if (thumbnailStream != null)
                {
                    args.OutputStream = thumbnailStream;
                }
            }
        }
    }
}

We need also to customize the MediaRequest to also take the “SmartCropping” parameter into account:

using Sitecore.Configuration;
using Sitecore.Diagnostics;
using Sitecore.Resources.Media;

using System.Web;

namespace Sitecore.Computer.Vision.CroppingImageField.Requests
{
    using System.Collections.Specialized;

    public class AICroppingMediaRequest : MediaRequest
    {
        private HttpRequest _innerRequest;
        private MediaUrlOptions _mediaQueryString;
        private MediaUri _mediaUri;
        private MediaOptions _options;

        protected override MediaOptions GetOptions()
        {
            var queryString = this.InnerRequest.QueryString;

            if (queryString == null || queryString.Count == 0)
            {
                _options = new MediaOptions();
            }
            else
            {
                SetMediaOptionsFromMediaQueryString(queryString);

                if (!string.IsNullOrEmpty(queryString.Get(Constants.QueryStringKeys.SmartCropping)))
                {
                    SetCustomOptionsFromQueryString(queryString);
                }
            }

            if (!this.IsRawUrlSafe)
            {
                if (Settings.Media.RequestProtection.LoggingEnabled)
                {
                    string urlReferrer = this.GetUrlReferrer();

                    Log.SingleError(string.Format("MediaRequestProtection: An invalid/missing hash value was encountered. " +
                        "The expected hash value: {0}. Media URL: {1}, Referring URL: {2}",
                        HashingUtils.GetAssetUrlHash(this.InnerRequest.RawUrl), this.InnerRequest.RawUrl,
                        string.IsNullOrEmpty(urlReferrer) ? "(empty)" : urlReferrer), this);
                }

                _options = new MediaOptions();
            }

            return _options;
        }

        private void SetCustomOptionsFromQueryString(NameValueCollection queryString)
        {
            this.ProcessCustomParameters(_options);

            if (!string.IsNullOrEmpty(queryString.Get(Constants.QueryStringKeys.SmartCropping))
                    && !_options.CustomOptions.ContainsKey(Constants.QueryStringKeys.SmartCropping)
                    && !string.IsNullOrEmpty(queryString.Get(Constants.QueryStringKeys.SmartCropping)))
            {
                _options.CustomOptions.Add(Constants.QueryStringKeys.SmartCropping, queryString.Get(Constants.QueryStringKeys.SmartCropping));
            }
        }

        private void SetMediaOptionsFromMediaQueryString(NameValueCollection queryString)
        {
            MediaUrlOptions mediaQueryString = this.GetMediaQueryString();

            _options = new MediaOptions()
            {
                AllowStretch = mediaQueryString.AllowStretch,
                BackgroundColor = mediaQueryString.BackgroundColor,
                IgnoreAspectRatio = mediaQueryString.IgnoreAspectRatio,
                Scale = mediaQueryString.Scale,
                Width = mediaQueryString.Width,
                Height = mediaQueryString.Height,
                MaxWidth = mediaQueryString.MaxWidth,
                MaxHeight = mediaQueryString.MaxHeight,
                Thumbnail = mediaQueryString.Thumbnail,
                UseDefaultIcon = mediaQueryString.UseDefaultIcon
            };

            if (mediaQueryString.DisableMediaCache)
            {
                _options.UseMediaCache = false;
            }

            foreach (string allKey in queryString.AllKeys)
            {
                if (allKey != null && queryString[allKey] != null)
                {
                    _options.CustomOptions[allKey] = queryString[allKey];
                }
            }
        }

        public override MediaRequest Clone()
        {
            Assert.IsTrue((base.GetType() == typeof(AICroppingMediaRequest)), "The Clone() method must be overridden to support prototyping.");

            return new AICroppingMediaRequest
            {
                _innerRequest = this._innerRequest,
                _mediaUri = this._mediaUri,
                _options = this._options,
                _mediaQueryString = this._mediaQueryString
            };
        }
    }
}

This code is very straightforward, it will basically check if the “SmartCropping=true” parameter exists in the media request, and then executes the custom code to crop the image.

The “Get Thumbnails” method limitations

As we can see in the official documentation, there are some limitations on the thumbnail generator method.

  • Image file size must be less than 4MB.
  • Image dimensions should be greater than 50 x 50.
  • Width of the thumbnail must be between 1 and 1024.
  • Height of the thumbnail must be between 1 and 1024.

The most important one is that the width and height cannot exceed the 1024px, this is problematic as sometimes we need to crop on a bigger ratio.

So, in order to make it more flexible, I’m doing the cropping using the Graphics library but getting the focus point coordinates from the “Get Area Of Interest” API method:

using Sitecore.Data.Items;
using Microsoft.Extensions.DependencyInjection;
using System.IO;
using Sitecore.DependencyInjection;
using Sitecore.Resources.Media;
using System.Drawing;
using System.Drawing.Imaging;
using System.Drawing.Drawing2D;

namespace Sitecore.Computer.Vision.CroppingImageField.Services
{
    public class CroppingService : ICroppingService
    {
        private readonly ICognitiveServices _cognitiveServices;

        public CroppingService(ICognitiveServices cognitiveServices)
        {
            _cognitiveServices = cognitiveServices;
        }

        public CroppingService()
        {
            _cognitiveServices = ServiceLocator.ServiceProvider.GetService<ICognitiveServices>();
        }

        public Stream GetCroppedImage(int width, int height, MediaItem mediaItem)
        {
            using (var streamReader = new MemoryStream())
            {
                var mediaStrm = mediaItem.GetMediaStream();

                mediaStrm.CopyTo(streamReader);
                mediaStrm.Position = 0;

                var img = Image.FromStream(mediaStrm);

                // The cropping size shouldn't be higher than the original image
                if (width > img.Width || height > img.Height)
                {
                    Sitecore.Diagnostics.Log.Warn($"Media file is smaller than the requested crop size. " +
                        $"This can result on a low quality result. Please upload a proper image: " +
                        $"Min Height:{height}, Min Width:{width}. File: {mediaItem.DisplayName}, Path{mediaItem.MediaPath}", this);
                }

                // if the cropping size exceeds the cognitive services limits, get the focus point and crop 
                if (width > 1025 || height > 1024)
                {

                    var area = _cognitiveServices.GetAreaOfImportance(streamReader.ToArray());
                    var cropImage = CropImage(img, area.areaOfInterest.X, area.areaOfInterest.Y, width, height);

                    return cropImage;
                }

                var thumbnailResult = _cognitiveServices.GetThumbnail(streamReader.ToArray(), width, height);

                return new MemoryStream(thumbnailResult);
            }
        }

        public string GenerateThumbnailUrl(int width, int height, MediaItem mediaItem)
        {
            var streamReader = MediaManager.GetMedia(mediaItem).GetStream();
            {
                using (var memStream = new MemoryStream())
                {
                    streamReader.Stream.CopyTo(memStream);

                    var thumbnail = _cognitiveServices.GetThumbnail(memStream.ToArray(), width, height);
                    var imreBase64Data = System.Convert.ToBase64String(thumbnail);

                    return $"data:image/png;base64,{imreBase64Data}";
                }
            }
        }

        private Stream CropImage(Image source, int x, int y, int width, int height)
        {
            var bmp = new Bitmap(width, height);
            var outputStrm = new MemoryStream();

            using (var gr = Graphics.FromImage(bmp))
            {
                gr.InterpolationMode = InterpolationMode.HighQualityBicubic;
                using (var wrapMode = new ImageAttributes())
                {
                    wrapMode.SetWrapMode(WrapMode.TileFlipXY);
                    gr.DrawImage(source, new Rectangle(0, 0, bmp.Width, bmp.Height), x, y, width, height, GraphicsUnit.Pixel, wrapMode);
                }
            }

            bmp.Save(outputStrm, source.RawFormat);

            return outputStrm;
        }
    }
}

Let’s see this in action!

After picking your picture in the AI Cropping Image field, it gets already cropped and you can see the different thumbnails. You can choose or change the thumbnails by updating the child items here: /sitecore/system/Settings/Foundation/Vision/Thumbnails.

Also note that you get an auto generated Alt text “Diego Maradona holding a ball” and a list of tags.

AI Cropping Image Field
AI Cropping Image Field

The results

This is how the different cropped images will look like in the front end. Depending on your front end implementation, you will define different cropping sizes per breakpoints.

In this following implementation, I’m setting the image as a background and using the option to render the image URL as follows:

<div class="heroBanner__backgroundWrapper">
    <div v-animate-on-inview="{class: 'animateScaleOut', delay: 10}"
         v-animate-on-scroll="{class: 'animateOverlay'}"
         class="heroBanner__background @Model.HeroClass" role="img" aria-label="@Model.GlassModel.ProductHeroImage.Alt"
         v-background="{
                        '0': '@Html.Sitecore().AICroppingImageField("AI Image", Model.GlassModel.Item, new AdvancedImageParameters {Width = 600, Height = 600, OnlyUrl = true})',
                        '360': '@Html.Sitecore().AICroppingImageField("AI Image", Model.GlassModel.Item, new AdvancedImageParameters {Width = 900, Height = 900, OnlyUrl = true})',
                        '720': '@Html.Sitecore().AICroppingImageField("AI Image", Model.GlassModel.Item, new AdvancedImageParameters {Width = 1667, Height = 750, OnlyUrl = true})',
                        '1280': '@Html.Sitecore().AICroppingImageField("AI Image", Model.GlassModel.Item, new AdvancedImageParameters {Width = 2000, Height = 900, OnlyUrl = true})'
                        }">
    </div>
Tablet Version
Tablet
Desktop Version
Desktop
Mobile Version
Mobile

Sitecore Media Cache

As I mentioned before, the cropped images are also stored in the media cache, as we can confirm by checking the media cache folder

The Sitecore media cache

Other usages and helpers

Sitecore HTML helper

You can use the @Sitecore.Html helper to render an image tag, as usual, or to generate just the URL of the image (src).

Code
@Html.Sitecore().AICroppingImageField("AI Image", Model.Item, new AdvancedImageParameters { Width = 600, Height = 600, AutoAltText = true })
Result
<img alt="a close up of a person wearing glasses"
 src="https://vision.test.cm/-/media/project/vision/homepage/iatestimage.png?
w=600&h=600&smartCropping=true&hash=C2E215FE2CF74D4C8142E35619ABB8DE">

Note: Have a look at the AdvancedImageParameters:

  • OnlyUrl: If true it will just render the image URL (for being used as src in the img tag).
  • AutoAltText: If true, the alt text will be replaced by the one generated from Azure IA.
  • Width and Height: int values, to specify the cropping size.
  • Widths and Sizes: If set, it will generate a srcset image with for the different breakpoints.
  • SizesTag and SrcSetTag: Those are mandatories if when using the previous settings.
Code
@Html.Sitecore().AICroppingImageField("AI Image", Model.Item, new 
AdvancedImageParameters {Widths = "170,233,340,466", Sizes = "50vw,(min-width: 
999px) 25vw,(min-width: 1200px) 15vw", SizesTag = "data-sizes", SrcSetTag = "data-
srcset", AutoAltText = true })
Result
<img alt="a close up of a person wearing glasses" data-sizes="50vw,(min-width: 
999px) 25vw,(min-width: 1200px) 15vw" data-
srcset="https://vision.test.cm/-/media/project/vision/homepage/iatestimage.png?
w=170&hash=1D04C1F551E9606AB2EEB3C712255651 
170w,https://vision.test.cm/-/media/project/vision/homepage/iatestimage.png?
w=233&hash=DD2844D340246D3CF8AEBB63CE4E9397 
233w,https://vision.test.cm/-/media/project/vision/homepage/iatestimage.png?
w=340&hash=3B773ACB5136214979A0009E24F25F02 
340w,https://vision.test.cm/-/media/project/vision/homepage/iatestimage.png?
w=466&hash=424F7615FBECFED21F48DA0AE1FE7A5B 466w" 
src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==">

GlassMapper extension

At last, an extension method has been added in order to get the media URL from the image field.

Code
<img src="@Model.AiImage.GetImageUrl(600, 600)" />
Result
<img src="https://vision.test.cm/-/media/project/vision/homepage/iatestimage.png?
w=600&h=600&smartCropping=true&hash=C2E215FE2CF74D4C8142E35619ABB8DE">

Sitecore Package and code

Please find the whole implementation in my GitHub repo, also feel free to contribute 🙂

You can also download the Sitecore package from here. Note: It has been tested on Sitecore 8.2, 9.x and 10.x.

You can also get the Docker asset image from Docker Hub!

docker pull miguelminoldo/sitecore.computer.vision

That’s it! I hope you find it interesting and useful! Any feedback is always welcome!