Sitecore Multisite Shenanigans #1: Create a dynamic Sitemap.xml

Hello fellow developers and welcome to the first part of an ongoing blog series revolving around minor and major improvements on your future multisite solution. Over the next couple of weeks we will talk about improving on some of the more relevant functional aspects you might want to consider when juggling with multiple website nodes within your Sitecore content tree.

The Challenge

In a common multisite environment, your website folder usually hosts a multitude of logically independent websites. The way your average Sitemap.xml works is to place said file in the root folder for services like Google to consume its content by accessing http://www.hostname.com/sitemap.xml.

To provide an example, let’s say your Sitecore content tree consists of the following four website nodes:

  • Orange
  • Banana
  • Peach
  • Pineapple

Each of those nodes is configured to be running under a corresponding hostname, resulting in something like

Consequently, your resulting Sitemap.xml should only contain content related to each specific website. Right?

Well, how is that supposed to work then, when your Sitemap.xml file is bound to be placed in your root folder and each one of your websites share the same physical location?

The Solution

Fret not, for we will find an elegant solution to this problem.

The Template

Create a template structure to hold your data.

template

We want to have a Multi-Line Text field to hold our XML content and a checkbox in case we don’t want to create the xml for a specific website.

Assign this new template to your ‘homepage’ template.

2016-09-20-20_12_03-template_homepage

The Job

With your data template in place, we want to create a scheduled job taking care of creating the content of our site specific Sitemap.xml.

2016-09-20-20_15_12-content-editor_scheduled_task

Create the corresponding class and add your code as follows.

</pre>
<pre>/// <summary>
/// Job to generate XML sitemap for each website selected in corresponding sitemap configuration item
/// </summary></pre>
<pre>public class SitemapJob
{
    private readonly IEnumerable<SiteInfo> _websitesToGenerate;

    public SitemapJob()
    {
        Sitecore.Diagnostics.Log.Info("Sitemap Job has started.", this);
        // retrieve the configration item and websites to generate
        List<SiteInfo> websites = SiteContextFactory.Sites;
        // get all sites where a value for "targethostname" was provided
        _websitesToGenerate = websites.Where(x => !x.TargetHostName.IsNullOrEmpty());

    }

    public void Execute()
    {
        // Load configuration from config file
        var sitemapConfiguration = new SitemapConfiguration();

        if (!_websitesToGenerate.Any())
        {
            Sitecore.Diagnostics.Log.Warn("Sitemap Job has ended prematurely, because no website contexts was found.", this);
            return;
        }

        foreach (var website in _websitesToGenerate)
        {
            SiteContext siteContext = Factory.GetSite(website.Name);
            if (siteContext == null)
            {
                Sitecore.Diagnostics.Log.Warn("Site context for " + website.Name + "was not found.", this);
                continue;
            }

            var mappedItems = new List<SitemapItem>();
            using (new LanguageSwitcher(siteContext.Language))
            {
                ISitemapItemProvider sitemapItemProvider = new SitemapItemProvider(sitemapConfiguration, siteContext);
                mappedItems.AddRange(sitemapItemProvider.SitemapItems()); // get list of SitemapItems
            }

            Item homeItem = Context.ContentDatabase.GetItem(siteContext.StartPath);
            // only execute if home item not null, mapped items have been found and the checkbox field has not been ticked.
            if (homeItem == null || !mappedItems.Any() ||
                homeItem.Fields[ID.Parse("{954D67DC-FC84-42CC-938D-B1070EE686B7}")].Value == "1") continue;

            var sitemapXmlWriter = new SitemapXmlWriter(homeItem, sitemapConfiguration);
            sitemapXmlWriter.WriteXmlSitemapToHomeItem(mappedItems);

            // publish item
            Publish.PublishItem(homeItem, false);
        }

        Sitecore.Diagnostics.Log.Info("Sitemap Job has ended successfully.", this);

    }
}</pre>
<pre>

The Configuration

We’re gonna add a couple of configuration options to our solution, to e.g. exclude certain templates or paths.


public class SitemapConfiguration
{
public string Domain
{
get
{
return GetValueByName("Domain");
}
}

public string WorkingDatabase
{
get
{
return GetValueByName("workingDatabase");
}
}

public string XmlnsXhtml
{
get
{
return GetValueByName("xmlnsXhtml");
}
}

public string XmlnsTpl
{
get
{
return GetValueByName("xmlnsTpl");
}
}

public string Protocol
{
get
{
return GetValueByName("sitemapHttpProtocol");
}
}

public List<string> ExcludedTemplates
{
get
{
return GetListByName("sitemapTemplates/sitemapTemplate", "id");
}
}

public List<string> ExcludedPaths
{
get
{
return GetListByName("sitemapExcludedPaths/sitemapExcludedPath", "path");
}
}

private static string GetValueByName(string name)
{
string result = string.Empty;

foreach (XmlNode node in Factory.GetConfigNodes("sitemapVariables/sitemapVariable"))
{
if (XmlUtil.GetAttribute("name", node) == name)
{
result = XmlUtil.GetAttribute("value", node);
break;
}
}

return result;
}

private static List<string> GetListByName(string configNodes, string name)
{
var resultList = new List<string>();

foreach (XmlNode node in Factory.GetConfigNodes(configNodes))
{
resultList.Add(XmlUtil.GetAttribute(name, node));
}

return resultList;
}
}

The corresponding configuration file looks something like this.


<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<sitemapVariables>
<sitemapVariable name="xmlnsTpl" value="http://www.sitemaps.org/schemas/sitemap/0.9" />
<sitemapVariable name="xmlnsXhtml" value="http://www.w3.org/1999/xhtml" />
<sitemapVariable name="workingDatabase" value="web" />
<sitemapVariable name="outputFileName" value="Sitemap.xml" />
<sitemapVariable name="outputHtmlFileName" value="Sitemap.html" />
<sitemapVariable name="sitemapHttpProtocol" value="https" />
</sitemapVariables>
<sitemapExcludedTemplates>
<sitemapTemplate id="{A87A00B1-E6DB-45AB-8B54-636FEC3B5523}" name="folder" />
</sitemapExcludedTemplates>
<sitemapExcludedPaths>
<!--<sitemapExcludedPath path="/sitecore/content/xxx/Home/Demo" />--></sitemapExcludedPaths>
<pipelines>
<initialize>
<processor type="Init.xxx.Shell.Pipelines.Sitemap.RegisterSitemapRoute, Init.xxx.WR.Shell" />
</initialize>
</pipelines>
</sitecore>
</configuration>

The most important setting here is the working database being set to ‘web’, in order to avoid having unpublished content showing up in your sitemap.xml.

The Sitemap item provider

The Sitemap item provider retrieves all the items from a provided website and maps them to required XML format for each language the item exists in.


public class SitemapItemProvider : ISitemapItemProvider
{
private readonly Database _database;
private readonly SiteContext _siteContext;

private SitemapConfiguration _config;

public SitemapItemProvider(SitemapConfiguration config, SiteContext siteContext)
{
_config = config;
_database = Factory.GetDatabase(_config.WorkingDatabase);
_siteContext = siteContext;
}

public List<SitemapItem> SitemapItems()
{
List<SitemapItem> result = new List<SitemapItem>();
using (new SiteContextSwitcher(_siteContext))
{
var contentRoot = _database.GetItem(_siteContext.StartPath);
if (contentRoot == null)
{
Sitecore.Diagnostics.Log.Warn(
"Unable to generate Sitemap XML for Site [" + _siteContext.Name + "]. Item by Startpath [" +
_siteContext.StartPath + "] is null", _siteContext);
return null;
}

if (!contentRoot.Axes.GetDescendants().Any()) return result;
// Get all descendants with minus the excluded templates defined in config file
var descendants = contentRoot.Axes.GetDescendants().Where(i => _config.ExcludedTemplates.All(excludedTemplate => i.TemplateID != ID.Parse(excludedTemplate))); ;
// Remove all items within the excluded paths
var clearedDescendants = descendants.Where(i => !_config.ExcludedPaths.Any(excludedPaths => i.Paths.FullPath.StartsWith(excludedPaths)));

result.AddRange(clearedDescendants.Select(MapItem).ToList());
}

return result;
}

private SitemapItem MapItem(Item sitecoreItem)
{
var urlOptions = UrlOptions.DefaultOptions;
urlOptions.ShortenUrls = false;
urlOptions.Site = _siteContext;

var sitemapItem = new SitemapItem(sitecoreItem.ID.Guid);
var languageItems = sitecoreItem.Languages;
foreach (var languageItem in languageItems)
{
var currentItem = _database.GetItem(sitecoreItem.ID, languageItem);
if (currentItem.Versions.Count <= 0) continue;

sitemapItem.ItemList.Add(new SitemapLanguageVariant
{
Location = string.Format("{0}{1}", Config.Schema, LinkManager.GetItemUrl(currentItem, urlOptions2)),
LastModified = currentItem.Statistics.Updated.ToLocalTime().ToString("yyyy-MM-ddTHH:mm:sszzz"),
Hreflang = currentItem.Language.CultureInfo.Name,
Priority = string.Empty,
ChangeFrequency = string.Empty
});
}
return sitemapItem;
}
}

We need two more classes for our provider, the SitemapItem and a corresponding SitemapLanguageVariant class.


public class SitemapItem
{
public Guid Id { get; private set; }
public List<SitemapLanguageVariant> ItemList;

public SitemapItem(Guid id)
{
Id = id;
ItemList = new List<SitemapLanguageVariant>();
}
}


public class SitemapLanguageVariant
{
public string ChangeFrequency { get; set; }
public string Hreflang { get; set; }
public string Location { get; set; }
public string LastModified { get; set; }
public string Priority { get; set; }
}

 

The Sitemap XML Writer

The Sitemap XML Writer generates the resulting XML content and saves the content to the homepage item provided.


public class SitemapXmlWriter
{
private readonly Item _homeItem;
private readonly SitemapConfiguration _config;

public SitemapXmlWriter(Item homeItem, SitemapConfiguration config)
{
_homeItem = homeItem;
_config = config;
}

public void WriteXmlSitemapToHomeItem(List<SitemapItem> sitemapItems)
{
string xmlContent = BuildXmlDocument(sitemapItems);

using (new EditContext(_homeItem))
{

// saves the content to your custom template field
_homeItem.Fields["{BB51942B-BB21-49CE-8F44-A947E462AC73}"].Value = xmlContent;
}
}
private string BuildXmlDocument(List<SitemapItem> sitemapItems)
{
// build the head etc. of the file
var sitemapXmlDoc = BuildXmlHead();

foreach (var sitemapItem in sitemapItems)
{
sitemapXmlDoc = BuildSitemapItem(sitemapXmlDoc, sitemapItem);
}

return sitemapXmlDoc.OuterXml;
}

private XmlDocument BuildXmlHead()
{
var xmlDocument = new XmlDocument();

XmlNode declarationNode = xmlDocument.CreateXmlDeclaration("1.0", "UTF-8", null);
xmlDocument.AppendChild(declarationNode);
XmlNode urlsetNode = xmlDocument.CreateElement("urlset");
XmlAttribute xmlnsAttr = xmlDocument.CreateAttribute("xmlns");
xmlnsAttr.Value = _config.XmlnsTpl;
// ReSharper disable once PossibleNullReferenceException
urlsetNode.Attributes.Append(xmlnsAttr);
XmlAttribute xmlnsXhtmlAttr = xmlDocument.CreateAttribute("xmlns:xhtml");
xmlnsXhtmlAttr.Value = _config.XmlnsXhtml;
urlsetNode.Attributes.Append(xmlnsXhtmlAttr);

xmlDocument.AppendChild(urlsetNode);

return xmlDocument;
}

private XmlDocument BuildSitemapItem(XmlDocument sitemapXmlDoc, SitemapItem item)
{
XmlNode urlsetNode = sitemapXmlDoc.LastChild;

foreach (var languageVariantOuter in item.ItemList)
{
// URL
XmlNode urlNode = sitemapXmlDoc.CreateElement("url");
urlsetNode.AppendChild(urlNode);

// Location
XmlNode locNode = sitemapXmlDoc.CreateElement("loc");
urlNode.AppendChild(locNode);
locNode.AppendChild(sitemapXmlDoc.CreateTextNode(languageVariantOuter.Location));

// Lastmodified
XmlNode lastmodNode = sitemapXmlDoc.CreateElement("lastmod");
urlNode.AppendChild(lastmodNode);
lastmodNode.AppendChild(sitemapXmlDoc.CreateTextNode(languageVariantOuter.LastModified));

// Change frequently
if (!string.IsNullOrWhiteSpace(languageVariantOuter.ChangeFrequency))
{
XmlNode changeFrequencyNode = sitemapXmlDoc.CreateElement("changefreq");
urlNode.AppendChild(changeFrequencyNode);
changeFrequencyNode.AppendChild(sitemapXmlDoc.CreateTextNode(languageVariantOuter.ChangeFrequency));
}

// Priority
if (!string.IsNullOrWhiteSpace(languageVariantOuter.Priority))
{
var priorityNode = sitemapXmlDoc.CreateElement("priority");
urlNode.AppendChild(priorityNode);
priorityNode.AppendChild(sitemapXmlDoc.CreateTextNode(languageVariantOuter.Priority));
}

// Alternate link list for different languages
if (item.ItemList.Count <= 1) continue;
foreach (var languageVariantInner in item.ItemList)
{
XmlNode linkNode = sitemapXmlDoc.CreateElement("xhtml:link");

XmlAttribute relAttr = sitemapXmlDoc.CreateAttribute("rel");
relAttr.Value = "alternate";
// ReSharper disable once PossibleNullReferenceException
linkNode.Attributes.Append(relAttr);

XmlAttribute hreflangAttr = sitemapXmlDoc.CreateAttribute("hreflang");
hreflangAttr.Value = languageVariantInner.Hreflang;
linkNode.Attributes.Append(hreflangAttr);

XmlAttribute hrefAttr = sitemapXmlDoc.CreateAttribute("href");
hrefAttr.Value = languageVariantInner.Location;
linkNode.Attributes.Append(hrefAttr);

urlNode.AppendChild(linkNode);
}
}

return sitemapXmlDoc;
}

}

 

The Schedule

Add the schedule to list list of other schedules and set the interval you want your job to run.

2016-09-22-15_47_56-desktop_sitemapschedule

 

The Route

To resolve any calls made to http://www.yoururl.com/sitemap.xml you want to register a custom route. For this we patch in a custom processor.


public class RegisterSitemapRoute
{
/// <summary>
/// </summary>
/// <param name="args"></param>
public virtual void Process(PipelineArgs args)
{
RegisterRoutes(RouteTable.Routes, args);
}

/// <summary>
/// </summary>
/// <param name="routes"></param>
/// <param name="args"></param>
private void RegisterRoutes(RouteCollection routes, PipelineArgs args)
{
routes.MapRoute("sitemap.xml", "sitemap.xml",
new { controller = "Sitemap", action = "SitemapContent" },
new[] { "xxx.Sitecore.Sitemap.Controllers" });
}
}

The registered route points to a custom controller and action.

The Controller

The last part of our implementation is our controller action bringing together all the parts to render the Sitemap’s content.


public class SitemapController : BaseController
{
private ID HomepageTemplateId { get; set; }
private readonly Item _homePage;

/// <summary>
/// </summary>
public SitemapController()
{
Sitecore.Context.Site = Sitecore.Context.Site ??
Init.Ulm.WR.Kernel.SiteResolving.SiteContextService.GetSiteContext(System.Web.HttpContext.Current.Request.Url);
var homepageTemplate = Settings.GetSetting("Init.Toolbox.Sitecore.Robots.HomepageTemplateID");
ID homepageTemplateId;

if (ID.TryParse(homepageTemplate, out homepageTemplateId))
{
HomepageTemplateId = homepageTemplateId;
}
else
{
throw new Exception("Please configure Init.Toolbox.Sitecore.Robots.HomepageTemplateID");
}

var startPath = Sitecore.Context.Site.StartPath;
_homePage = Sitecore.Context.Database.GetItem(startPath);
}

/// <summary>
/// Retrieves sitemap content value from related homepage item
/// </summary>
/// <returns></returns>
public FileContentResult SitemapContent()
{
var content = _homepage.Fields["SitemapContentXml"].Value;

return new FileContentResult(Encoding.UTF8.GetBytes(content), "text/xml");
}
}

The Result

Browsing the sitemap.xml for one of your specified hostnames should now retrieve the content collected from the SitemapJob.

2016-09-20-21_30_32-mozilla-firefox

Sitecore Tidbits: Add a direction to your WFFM radiolist

A minor omission I stumbled over today when asked by one of our web developers revolved around the “direction” property for the WFFM Radio List field type having no effect on the rendered output.

2016-09-19-15_42_45-content-editor

Looking into the corresponding RadioListField.cshtml file the property was indeed nowhere to be found. Booting up my debugger I was able to find the property stowed away in the “Parameters” property for Sitecore.Forms.Mvc.ViewModels.Fields.RadioListField.

2016-09-19-15_49_19-clipboard01-irfanview-zoom_-5412-x-1107

With this revelation in mind, all you have to do is add the property to your markup, e.g. like this.


@using Sitecore.Forms.Mvc.Html
@using Sitecore.Forms.Mvc.ViewModels.Fields
@using Sitecore.StringExtensions
@model RadioListField
@{
string direction;
Model.Parameters.TryGetValue("direction", out direction);
if (!direction.IsNullOrEmpty())
{
direction = direction.ToLower();
}
}
<div class="c-inputs-stacked">
@using (Html.BeginField())
{
foreach (var item in Model.Items)
{
<div class="radio c-input c-radio @direction">
<label>
@Html.RadioButtonFor(x => Model.Value, item.Value, (item.Selected) ? new { Checked = "checked" } : null)
<span class="c-indicator"></span>
@item.Text
</label>
</div>
}
}
</div>

That’s it, tidbit exemplified.

Modify Sitecore Workbox to only show items the current editor has worked on.

So, what’s the issue here?

In general, the Sitecore workbox is a handy tool for editors to keep an easy watch on their day-to-day website content.

2016-09-11-20_28_57-workbox

 

It allows editors to easily move batches of content between workflow states, without navigating the Sitecore tree or having to use the search in order to find the pages they have been working on. Very practical, one would think.

Sitecore’s default workbox implementation has a couple of restrictions in place to limit the visibility on the interactable content displayed to the user, the obvious restriction being having been granted read access in order to see stuff.

With no other restrictions in place however, what usually happens is that content editors within a given editorial role see each others’ content in the Sitecore workbox. Even worse, they might actually be able to change the current condition of workflow states (Delete, Submit, Translate) on content they have no affiliation with.

With this in mind, wouldn’t it make more sense to limit the visibility of workbox content to only show the content to the user which he has actually worked on?

Implementation

Okay, let me explain to youhow to overcome this lack of content control in Sitecore.

Modifying the Sitecore workbox is fairly straightforward. If you take a look into your website folder, at sitecore\shell\Applications\Workbox there is a file named “Workbox.xml” containing the reference on the codebeside file we want to modify.

2016-09-11-18_47_47-c__websites_sc82rev160729_website_sitecore_shell_applications_workbox_workbox-xm

Open the referenced class at Sitecore.Shell.Applications.Workbox.WorkboxForm using a decompiler tool like dotPeek to extract the code within to a custom class we can modify to our heart’s content.

Once copied over, you’ll have to change a couple of namespaces and add the missing references first.

2016-09-11-18_49_47-c__websites_sc82rev160729_website_sitecore_shell_applications_workbox_workbox-xm

Restricting Visibility

Okay, so with our custom class in place, we would now like to restrict the visibility of the items loaded into the workbox.

To do so, navigate to the GetItems method.

 /// <summary>Gets the items.</summary>
 /// <param name="state">The state.</param>
 /// <param name="workflow">The workflow.</param>
 /// <returns>Array of item DataUri.</returns>
 private DataUri[] GetItems(WorkflowState state, IWorkflow workflow)
 {
 Assert.ArgumentNotNull((object) state, "state");
 Assert.ArgumentNotNull((object) workflow, "workflow");
 ArrayList arrayList = new ArrayList();
 DataUri[] items = workflow.GetItems(state.StateID);
 if (items != null)
 {
 foreach (DataUri index in items)
 {
 Item obj = Context.ContentDatabase.Items[index];
 if (obj != null && obj.Access.CanRead() && (obj.Access.CanReadLanguage() && obj.Access.CanWriteLanguage()) && (Context.IsAdministrator || obj.Locking.CanLock() || obj.Locking.HasLock()))
 arrayList.Add((object) index);
 }
 }
 return arrayList.ToArray(typeof (DataUri)) as DataUri[];
 }

In line 613 you can see the conditions required for an item to be loaded into the list of returned items displayed to the current user.

 if (obj != null && obj.Access.CanRead() && (obj.Access.CanReadLanguage() && obj.Access.CanWriteLanguage()) && (Context.IsAdministrator || obj.Locking.CanLock() || obj.Locking.HasLock()))

This is where we want to add our own condition.

Adding workflow history

You can retrieve an item’s workflow history, e.g. in order to see who’s last worked on this item.

string lastUser = String.Empty;
WorkflowEvent[] history = workflow.GetHistory(currentItem);
if (history.Length > 0)
{
    WorkflowEvent workflowEvent = history[history.Length - 1];
    lastUser = workflowEvent.User;
}

With this user information retrieved, we now add our own condition to the set.

if (currentItem != null
    && currentItem.Access.CanRead()
    && (currentItem.Access.CanReadLanguage()
    && (lastUser == Context.User.Name || Context.IsAdministrator)
    && currentItem.Access.CanWriteLanguage())
    && (currentItem.Locking.CanLock() || currentItem.Locking.HasLock()))
    arrayList.Add((object)index);

As you can see, there is a new condition where we check if the context user was the last user involved with this item. You can add more or-conditions for other roles (e.g. chief editors) to soften up the condition if needed.

    && (currentItem.Access.CanReadLanguage()
    && (lastUser == Context.User.Name || Context.User.IsInRole("sitecore\\chiefeditor")|| Context.IsAdministrator)
    && currentItem.Access.CanWriteLanguage())

The result on the GetItems method then looks something like this.

private DataUri[] GetItems(WorkflowState state, IWorkflow workflow)
{
    
    Assert.ArgumentNotNull((object)state, "state");
    Assert.ArgumentNotNull((object)workflow, "workflow");
    ArrayList arrayList = new ArrayList();
    DataUri[] items = workflow.GetItems(state.StateID);
    if (items != null)
    {
        foreach (DataUri index in items)
        {
            Item currentItem = Context.ContentDatabase.Items[index];
            if (currentItem == null)
                continue;
 
            // Adding workflow history to retrieve last user who worked on item
            string lastUser = String.Empty;
            WorkflowEvent[] history = workflow.GetHistory(currentItem);
            if (history.Length > 0)
            {
                WorkflowEvent workflowEvent = history[history.Length - 1];
                lastUser = workflowEvent.User;
            }
 
            if (currentItem != null
                && currentItem.Access.CanRead()
                && (currentItem.Access.CanReadLanguage()
                && (currentUser == Context.User.Name || Context.User.IsInRole("sitecore\\chiefeditor") || Context.IsAdministrator)
                && currentItem.Access.CanWriteLanguage())
                && (currentItem.Locking.CanLock() || currentItem.Locking.HasLock()))
                arrayList.Add((object)index);
        }
    }
 
    return arrayList.ToArray(typeof(DataUri)) as DataUri[];
}

So, are we done yet?

Almost, there’s one more block of code we have to modify. A bit further down you can find the method GetStateItems. 

 /// <summary>Gets the items in the workflow state.</summary>
 /// <param name="state">The state to get the items for.</param>
 /// <param name="workflow">The workflow the state belongs to.</param>
 /// <returns>The items for the state.</returns>
 private WorkboxForm.StateItems GetStateItems(WorkflowState state, IWorkflow workflow)
 {
 Assert.ArgumentNotNull((object) state, "state");
 Assert.ArgumentNotNull((object) workflow, "workflow");
 List<Item> objList = new List<Item>();
 List<string> stringList = new List<string>();
 DataUri[] items = workflow.GetItems(state.StateID);
 bool flag = items.Length > Settings.Workbox.StateCommandFilteringItemThreshold;
 if (items != null)
 {
 foreach (DataUri uri in items)
 {
 Item obj = Context.ContentDatabase.GetItem(uri);
 if (obj != null && obj.Access.CanRead() && (obj.Access.CanReadLanguage() && obj.Access.CanWriteLanguage()) && (Context.IsAdministrator || obj.Locking.CanLock() || obj.Locking.HasLock()))
 {
 objList.Add(obj);
 if (!flag)
 {
 foreach (WorkflowCommand filterVisibleCommand in WorkflowFilterer.FilterVisibleCommands(workflow.GetCommands(obj), obj))
 {
 if (!stringList.Contains(filterVisibleCommand.CommandID))
 stringList.Add(filterVisibleCommand.CommandID);
 }
 }
 }
 }
 }
 if (flag)
 {
 WorkflowCommand[] workflowCommandArray = WorkflowFilterer.FilterVisibleCommands(workflow.GetCommands(state.StateID));
 stringList.AddRange(((IEnumerable<WorkflowCommand>) workflowCommandArray).Select<WorkflowCommand, string>((Func<WorkflowCommand, string>) (x => x.CommandID)));
 }
 return new WorkboxForm.StateItems()
 {
 Items = (IEnumerable<Item>) objList,
 CommandIds = (IEnumerable<string>) stringList
 };
 }

If you take a look at line 637 you’ll see the same condition originally applied to the GetItems method we just modified.

 if (obj != null && obj.Access.CanRead() && (obj.Access.CanReadLanguage() && obj.Access.CanWriteLanguage()) && (Context.IsAdministrator || obj.Locking.CanLock() || obj.Locking.HasLock()))

With that in mind, I did a little refactoring on the workflow history and created a static method to save us some duplicate code.

 private static string GetLastUser(IWorkflow workflow, Item currentItem, string lastUser)
 {
 WorkflowEvent[] history = workflow.GetHistory(currentItem);
 if (history.Length > 0)
 {
 WorkflowEvent workflowEvent = history[history.Length - 1];
 lastUser = workflowEvent.User;
 }
 return lastUser;
 }

I also added another static method called checkIfItemValid to save us some additional lines of duplicate content.

private static bool checkIfItemValid(Item currentItem, string lastUser)
{
    return currentItem.Access.CanRead()
           && (currentItem.Access.CanReadLanguage()
               && currentItem.Access.CanWriteLanguage())
           && (lastUser == Context.User.Name || Context.User.IsInRole("sitecore\\chiefeditor") || Context.IsAdministrator)
           && (currentItem.Locking.CanLock() || currentItem.Locking.HasLock());

The final result on our GetStateItems method now looks like this.

/// <summary>
/// Gets the items in the workflow state.
/// 
/// </summary>
/// <param name="state">The state to get the items for.</param><param name="workflow">The workflow the state belongs to.</param>
/// <returns>
/// The items for the state.
/// </returns>
private CustomWorkboxForm.StateItems GetStateItems(WorkflowState state, IWorkflow workflow)
{
    Assert.ArgumentNotNull((object)state, "state");
    Assert.ArgumentNotNull((object)workflow, "workflow");
    List<Item> list1 = new List<Item>();
    List<string> list2 = new List<string>();
    DataUri[] items = workflow.GetItems(state.StateID);
    bool flag = items.Length > Settings.Workbox.StateCommandFilteringItemThreshold;
    if (items != null)
    {
        foreach (DataUri uri in items)
        {
            Item currentItem = Context.ContentDatabase.GetItem(uri);
            if(currentItem == null)
                continue;
 
            string lastUser = String.Empty;
            lastUser = GetLastUser(workflow, currentItem, lastUser);
 
            if (checkIfItemValid(currentItem, lastUser))
            {
                list1.Add(currentItem);
                if (!flag)
                {
                    foreach (WorkflowCommand workflowCommand in WorkflowFilterer.FilterVisibleCommands(workflow.GetCommands(currentItem), currentItem))
                    {
                        if (!list2.Contains(workflowCommand.CommandID))
                            list2.Add(workflowCommand.CommandID);
                    }
                }
            }
        }
    }
    if (flag)
    {
        WorkflowCommand[] workflowCommandArray = WorkflowFilterer.FilterVisibleCommands(workflow.GetCommands(state.StateID));
        list2.AddRange(Enumerable.Select<WorkflowCommand, string>((IEnumerable<WorkflowCommand>)workflowCommandArray, (Func<WorkflowCommand, string>)(x => x.CommandID)));
    }
    return new CustomWorkboxForm.StateItems()
    {
        Items = (IEnumerable<Item>)list1,
        CommandIds = (IEnumerable<string>)list2
    };
}

And that’s pretty much it! Save and build your code, update the class reference in Workbox.xml file and check your workbox if those changes have been applied.

About this Blog

Hello fellow coders and blog readers out there,

my name is Christopher Huemmer, writing to you from the city of Berlin on a rather cloudy day in Spring. Years of thoughtful consideration and being cooped up in the house has led me here, finally starting this (hopefully) ongoing developer blog.

So, why now? Well, calling myself a Sitecore / .NET developer for the past 5 years I felt like now’s the time to share some of my collected experience with other developers out there, some of which might be struggling with the same pitfalls in Sitecore or .NET development as I have in years prior.

I don’t know how many hours of my time as a professional I’ve spent sifting through countless blogs and Stackoverflow threads on minor or major web development topics, most times finding some suggestion or advice I could work with and sometimes finding nothing at all, leaving me to my own devices in coming up with a smart solution.

And this is where this blog comes in.

I hereby want to share some of the tidbits of useful knowledge I’ve collected over the years and also invite everyone to contribute, by commenting or sharing on the blog posts I’ll be publishing down the line.

Hope to hear more of you soon.

Salutations,
Christopher