Sitecore Technology MVP 2016
Sitecore MVP 2015
Sitecore MVP 2014

Display How Many Bucketed Items Live Within a Sitecore Item Bucket Using a Custom DataView

Over the past few weeks — if you haven’t noticed — I’ve been having a blast experimenting with Sitecore Item Buckets. It seems new ideas on what to build for it keep flooding my thoughts everyday. 😀

However, the other day an old idea that I wanted to solve a while back bubbled its way up into the forefront of my consciousness: displaying the count of Bucketed Items which live within each Item Bucket in the Content Tree.

I’m sure someone has built something to do this before though I didn’t really do any research on it as I was up for the challenge.

darth-vader-didnt-read

In all honesty, I enjoy spending my nights after work and on weekends building things in Sitecore — even if someone has built something like it before — as it’s a great way to not only discover new treasures hidden within the Sitecore assemblies, but also improve my programming skills — the saying “you lose it if you don’t use it” applies here.

nerds

You might be asking “Mike, we don’t store that many Sitecore Items in our Item Buckets; I can just go count them all by hand”.

Well, if that’s the case for you then you might want to reconsider why you are using the Item Buckets feature.

However, in theory, thousands if not millions of Items can live within an Item Bucket in Sitecore. If counting by hand is your thing — or even writing some sort of “script” (I’m not referring to PowerShell scripts that you would write using Sitecore PowerShell Extensions (SPE) — I definitely recommend harnessing all of the power this module has to offer — but instead to standalone ASP.NET Web Forms which some people erroneously call “scripts”) to generate some kind of report, then by all means go for it.

counting

That’s just not how I roll.

aint-no-time-for-that

So how are we going to display these counts to the user? We are ultimately going to create a custom Sitecore DataView.

If you aren’t familiar with DataViews in Sitecore, they basically allow you to change how Items are displayed in the Sitecore Content Tree.

I’m not going to go too much into details of how these work. I recommend having a read of the following posts by two fellow Sitecore MVPs for more information and to see other examples:

I do want to warn you: there is a lot of code in this post.

arghhhhh

You might want to go get a snack for this as it might take a while to get through all the code that I am showing here. Don’t worry, I’ll wait for you to get back.

eat-popcorn

Anyways, let’s jump right into it.

partay-meow

For this feature, I want to add a checkbox toggle in the Sitecore Ribbon to give users the ability turn this feature on and off.

bucketed-items-count-view-ribbon

In order to save the state of this checkbox, I defined the following interface:

namespace Sitecore.Sandbox.Web.UI.HtmlControls.Registries
{
    public interface IRegistry
    {
        bool GetBool(string key);

        bool GetBool(string key, bool defaultvalue);

        int GetInt(string key);

        int GetInt(string key, int defaultvalue);

        string GetString(string key);

        string GetString(string key, string defaultvalue);

        string GetValue(string key);

        void SetBool(string key, bool val);

        void SetInt(string key, int val);

        void SetString(string key, string value);

        void SetValue(string key, string value);
    }
}

Classes of the above interface will keep track of settings which need to be stored somewhere.

The following class implements the interface above:

namespace Sitecore.Sandbox.Web.UI.HtmlControls.Registries
{
    public class Registry : IRegistry
    {
        public virtual bool GetBool(string key)
        {
            return Sitecore.Web.UI.HtmlControls.Registry.GetBool(key);
        }

        public virtual bool GetBool(string key, bool defaultvalue)
        {
            return Sitecore.Web.UI.HtmlControls.Registry.GetBool(key, defaultvalue);
        }

        public virtual int GetInt(string key)
        {
            return Sitecore.Web.UI.HtmlControls.Registry.GetInt(key);
        }

        public virtual int GetInt(string key, int defaultvalue)
        {
            return Sitecore.Web.UI.HtmlControls.Registry.GetInt(key, defaultvalue);
        }

        public virtual string GetString(string key)
        {
            return Sitecore.Web.UI.HtmlControls.Registry.GetString(key);
        }

        public virtual string GetString(string key, string defaultvalue)
        {
            return Sitecore.Web.UI.HtmlControls.Registry.GetString(key, defaultvalue);
        }

        public virtual string GetValue(string key)
        {
            return Sitecore.Web.UI.HtmlControls.Registry.GetValue(key);
        }

        public virtual void SetBool(string key, bool val)
        {
            Sitecore.Web.UI.HtmlControls.Registry.SetBool(key, val);
        }

        public virtual void SetInt(string key, int val)
        {
            Sitecore.Web.UI.HtmlControls.Registry.SetInt(key, val);
        }

        public virtual void SetString(string key, string value)
        {
            Sitecore.Web.UI.HtmlControls.Registry.SetString(key, value);
        }

        public virtual void SetValue(string key, string value)
        {
            Sitecore.Web.UI.HtmlControls.Registry.SetValue(key, value);
        }
    }
}

I’m basically wrapping calls to methods on the static Sitecore.Web.UI.HtmlControls.Registry class which is used for saving state on the checkboxes in the Sitecore ribbon — it might be used for keeping track of other things in the Sitecore Content Editor though that is beyond the scope of this post. Nothing magical going on here.

I then defined the following interface for keeping track of Content Editor settings for things related to Item Buckets:

namespace Sitecore.Sandbox.Buckets.Settings
{
    public interface IBucketsContentEditorSettings
    {
        bool ShowBucketedItemsCount { get; set; }

        bool AreItemBucketsEnabled { get; }
    }
}

The ShowBucketedItemsCount boolean property lets the caller know if we are to show the Bucketed Items count, and the AreItemBucketsEnabled boolean property lets the caller know if the Item Buckets feature is enabled in Sitecore.

The following class implements the interface above:

using Sitecore.Diagnostics;

using Sitecore.Sandbox.Determiners.Features;
using Sitecore.Sandbox.Web.UI.HtmlControls.Registries;

namespace Sitecore.Sandbox.Buckets.Settings
{
    public class BucketsContentEditorSettings : IBucketsContentEditorSettings
    {
        protected IFeatureDeterminer ItemBucketsFeatureDeterminer { get; set; }
        
        protected IRegistry Registry { get; set; }

        protected string ShowBucketedItemsCountRegistryKey { get; set; }

        public bool ShowBucketedItemsCount
        {
            get
            {
                return ShouldShowBucketedItemsCount();
            }
            set
            {
                ToggleShowBucketedItemsCount(value);
            }
        }

        public bool AreItemBucketsEnabled
        {
            get
            {
                return GetAreItemBucketsEnabled();
            }
        }

        protected virtual bool ShouldShowBucketedItemsCount()
        {
            if (!AreItemBucketsEnabled)
            {
                return false;
            }

            EnsureRegistryDependencies();
            return Registry.GetBool(ShowBucketedItemsCountRegistryKey, false);
        }

        protected virtual void ToggleShowBucketedItemsCount(bool turnOn)
        {
            
            if (!AreItemBucketsEnabled)
            {
                return;
            }

            EnsureRegistryDependencies();
            Registry.SetBool(ShowBucketedItemsCountRegistryKey, turnOn);
        }

        protected virtual void EnsureRegistryDependencies()
        {
            Assert.IsNotNull(Registry, "Registry must be defined in configuration!");
            Assert.IsNotNullOrEmpty(ShowBucketedItemsCountRegistryKey, "ShowBucketedItemsCountRegistryKey must be defined in configuration!");
        }

        protected virtual bool GetAreItemBucketsEnabled()
        {
            Assert.IsNotNull(ItemBucketsFeatureDeterminer, "ItemBucketsFeatureDeterminer must be defined in configuration!");
            return ItemBucketsFeatureDeterminer.IsEnabled();
        }
    }
}

I’m injecting an IFeatureDeterminer instance into the instance of the class above via the Sitecore Configuration Factory — have a look at the patch configuration file further down in this post — specifically the ItemBucketsFeatureDeterminer which is defined in a previous blog post. The IFeatureDeterminer instance determines whether the Item Buckets feature is turned on/off (I’m not going to repost that code here so if you haven’t seen this code, please go have a look now so you have an understanding of what it’s doing).

Its instance is used in the GetAreItemBucketsEnabled() method which just delegates to its IsEnabled() method and returns the value from that call. The GetAreItemBucketsEnabled() method is used in the get accessor of the AreItemBucketsEnabled property.

I’m also injecting an IRegistry instance into the instance of the class above — this is also defined in the patch configuration file further down — which is used for storing/retrieving the value of the ShowBucketedItemsCount property.

It is leveraged in the ShouldShowBucketedItemsCount() and ToggleShowBucketedItemsCount() methods where a boolean value is saved or retrieved, respectively, in the Sitecore Registry under a certain key — this key is also injected into the ShowBucketedItemsCountRegistryKey property via the Sitecore Configuration Factory.

So, we now have a way to keep track of whether we should display the Bucketed Items count. We just need a way to let the user turn this on/off. To do that, I need to create a custom Sitecore.Shell.Framework.Commands.Command.

Since Sitecore Commands are instantiated by the CreateObject() method on the MainUtil class (this lives in the Sitecore namespace in Sitecore.Kernel.dll and isn’t as advanced as the Sitecore.Configuration.Factory class as it won’t instantiate nested objects defined in configuration as does the Sitecore Configuration Factory), I built the following Command which will decorate Commands defined in Sitecore configuration:

using System.Xml;

using Sitecore.Configuration;
using Sitecore.Diagnostics;
using Sitecore.Shell.Framework.Commands;
using Sitecore.Web.UI.HtmlControls;
using Sitecore.Xml;

namespace Sitecore.Sandbox.Shell.Framework.Commands
{
    public class ExtendedConfigCommand : Command
    {
        private Command command;
        protected Command Command
        {
            get
            {
                if(command == null)
                {
                    command = GetCommand();
                    EnsureCommand();
                }

                return command;
            }
        }
        
        protected virtual Command GetCommand()
        {
            XmlNode currentCommandNode = Factory.GetConfigNode(string.Format("commands/command[@name='{0}']", Name));
            string configPath = XmlUtil.GetAttribute("extendedCommandPath", currentCommandNode);
            Assert.IsNotNullOrEmpty(configPath, string.Format("The extendedCommandPath attribute must be set {0}!", currentCommandNode));
            Command command = Factory.CreateObject(configPath, false) as Command;
            Assert.IsNotNull(command, string.Format("The command defined at '{0}' was either not properly set or is not an instance of Sitecore.Shell.Framework.Commands.Command. Double-check it!", configPath));
            return command;
        }

        protected virtual void EnsureCommand()
        {
            Assert.IsNotNull(Command, "GetCommand() cannot return a null Sitecore.Shell.Framework.Commands.Command instance!");
        }

        public override void Execute(CommandContext context)
        {
            Command.Execute(context);
        }

        public override string GetClick(CommandContext context, string click)
        {
            return Command.GetClick(context, click);
        }

        public override string GetHeader(CommandContext context, string header)
        {
            return Command.GetHeader(context, header);
        }

        public override string GetIcon(CommandContext context, string icon)
        {
            return Command.GetIcon(context, icon);
        }

        public override Control[] GetSubmenuItems(CommandContext context)
        {
            return Command.GetSubmenuItems(context);
        }

        public override string GetToolTip(CommandContext context, string tooltip)
        {
            return Command.GetToolTip(context, tooltip);
        }

        public override string GetValue(CommandContext context, string value)
        {
            return Command.GetValue(context, value);
        }

        public override CommandState QueryState(CommandContext context)
        {
            return Command.QueryState(context);
        }
    }
}

The GetCommand() method reads the XmlNode for the current command, and gets the value set on its extendedCommandPath attribute. This value must to be a config path defined under the <sitecore> element in Sitecore configuration.

If the attribute doesn’t exist or is empty, or a Command instance isn’t properly created, an exception is thrown.

Otherwise, it is set on the Command property on the class.

All methods here delegate to the same methods on the Command stored in the Command property.

I then defined the following Command which will be used by the checkbox we are adding to the Sitecore Ribbon:

using Sitecore.Diagnostics;
using Sitecore.Shell.Framework.Commands;
using Sitecore.Web.UI.Sheer;

using Sitecore.Sandbox.Buckets.Settings;

namespace Sitecore.Sandbox.Buckets.Shell.Framework.Commands
{
    public class ToggleBucketedItemsCountCommand : Command
    {
        protected IBucketsContentEditorSettings BucketsContentEditorSettings { get; set; }

        public override void Execute(CommandContext context)
        {
            if (!AreItemBucketsEnabled())
            {
                return;
            }
            
            ToggleShowBucketedItemsCount();
            Reload();
        }

        protected virtual void ToggleShowBucketedItemsCount()
        {
            Assert.IsNotNull(BucketsContentEditorSettings, "BucketsContentEditorSettings must be defined in configuration!");
            BucketsContentEditorSettings.ShowBucketedItemsCount = !BucketsContentEditorSettings.ShowBucketedItemsCount;
        }

        protected virtual void Reload()
        {
            SheerResponse.SetLocation(string.Empty);
        }

        public override CommandState QueryState(CommandContext context)
        {
            if(!AreItemBucketsEnabled())
            {
                return CommandState.Hidden;
            }

            if(!ShouldShowBucketedItemsCount())
            {
                return CommandState.Enabled;
            }

            return CommandState.Down;
        }

        protected virtual bool AreItemBucketsEnabled()
        {
            Assert.IsNotNull(BucketsContentEditorSettings, "BucketsContentEditorSettings must be defined in configuration!");
            return BucketsContentEditorSettings.AreItemBucketsEnabled;
        }

        protected virtual bool ShouldShowBucketedItemsCount()
        {
            Assert.IsNotNull(BucketsContentEditorSettings, "BucketsContentEditorSettings must be defined in configuration!");
            return BucketsContentEditorSettings.ShowBucketedItemsCount;
        }
    }
}

The QueryState() method determines whether we should display the checkbox — it will only be displayed if the Item Buckets feature is on — and what the state of the checkbox should be — if we are currently showing Bucketed Items count, the checkbox will be checked (this is represented by CommandState.Down). Otherwise, it will be unchecked (this is represented by CommandState.Enabled).

The Execute() method encapsulates the logic of what we are to do when the user checks/unchecks the checkbox. It’s basically delegating to the ToggleShowBucketedItemsCount() method to toggle the value of whether we are to display the Bucketed Items count, and then reloads the Content Editor to refresh the display in the Content Tree.

I then had to define this checkbox in the Core database:

bucketed-items-count-checkbox-core

I’m not going to go into details of how the above works as I’ve written over a gazillion posts on the subject. I recommend having a read of one of these older posts.

After going back to my Master database, I saw the new checkbox in the Sitecore Ribbon:

buckted-items-count-new-checkbox

Since we could be dealing with thousands — if not millions — of Bucketed Items for each Item Bucket, we need a performant way to grab the count of these Items. In this solution, I am leveraging the Sitecore.ContentSearch API to get these counts though needed to add some custom
Computed Index Field classes:

using Sitecore.Buckets.Managers;
using Sitecore.Configuration;
using Sitecore.ContentSearch;
using Sitecore.ContentSearch.ComputedFields;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;

using Sitecore.Sandbox.Buckets.Util.Methods;

namespace Sitecore.Sandbox.Buckets.ContentSearch.ComputedFields
{
    public class IsBucketed : AbstractComputedIndexField
    {
        protected IItemBucketsFeatureMethods ItemBucketsFeatureMethods { get; private set; }

        public IsBucketed()
        {
            ItemBucketsFeatureMethods = GetItemBucketsFeatureMethods();
            Assert.IsNotNull(ItemBucketsFeatureMethods, "GetItemBucketsFeatureMethods() cannot return null!");
        }

        protected virtual IItemBucketsFeatureMethods GetItemBucketsFeatureMethods()
        {
            IItemBucketsFeatureMethods methods = Factory.CreateObject("buckets/methods/itemBucketsFeatureMethods", false) as IItemBucketsFeatureMethods;
            Assert.IsNotNull(methods, "the IItemBucketsFeatureMethods instance was not defined properly in /sitecore/buckets/methods/itemBucketsFeatureMethods!");
            return methods;
        }

        public override object ComputeFieldValue(IIndexable indexable)
        {
            Item item = indexable as SitecoreIndexableItem;
            if (item == null)
            {
                return null;
            }

            
            return IsBucketable(item) && IsItemContainedWithinBucket(item);
        }

        protected virtual bool IsBucketable(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            return BucketManager.IsBucketable(item);
        }

        protected virtual bool IsItemContainedWithinBucket(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            if(IsItemBucket(item))
            {
                return false;
            }

            return ItemBucketsFeatureMethods.IsItemContainedWithinBucket(item);
        }

        protected virtual bool IsItemBucket(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            if (!ItemBucketsFeatureMethods.IsItemBucket(item))
            {
                return false;
            }

            return true;
        }
    }
}

An instance of the class above ultimately determines if an Item is bucketed within an Item Bucket, and passes a boolean value to its caller denoting this via its ComputeFieldValue() method.

What determines whether an Item is bucketed? The code above says it’s bucketed only when the Item is bucketable and is contained within an Item Bucket.

The IsBucketable() method above ascertains whether the Item is bucketable by delegating to the IsBucketable() method on the BucketManager class in Sitecore.Buckets.dll.

The IsItemContainedWithinBucket() method determines if the Item is contained within an Item Bucket — you might be laughing as the name on the method is self-documenting — by delegating to the IsItemContainedWithinBucket() method on the IItemBucketsFeatureMethods instance — I’ve defined the code for this in this post so go have a look.

Moreover, the code does not consider Item Buckets to be Bucketed as that just doesn’t make much sense. 😉 This would also give us an inaccurate count.

The following Computed Index Field’s ComputeFieldValue() method returns the string representation of the ancestor Item Bucket’s Sitecore.Data.ID for the Item — if it is contained within an Item Bucket:

using Sitecore.Configuration;
using Sitecore.ContentSearch;
using Sitecore.ContentSearch.ComputedFields;
using Sitecore.ContentSearch.Utilities;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;

using Sitecore.Sandbox.Buckets.Util.Methods;

namespace Sitecore.Sandbox.Buckets.ContentSearch.ComputedFields
{
    public class ItemBucketAncestorId : AbstractComputedIndexField
    {
        protected IItemBucketsFeatureMethods ItemBucketsFeatureMethods { get; private set; }

        public ItemBucketAncestorId()
        {
            ItemBucketsFeatureMethods = GetItemBucketsFeatureMethods();
            Assert.IsNotNull(ItemBucketsFeatureMethods, "GetItemBucketsFeatureMethods() cannot return null!");
        }

        protected virtual IItemBucketsFeatureMethods GetItemBucketsFeatureMethods()
        {
            IItemBucketsFeatureMethods methods = Factory.CreateObject("buckets/methods/itemBucketsFeatureMethods", false) as IItemBucketsFeatureMethods;
            Assert.IsNotNull(methods, "the IItemBucketsFeatureMethods instance was not defined properly in /sitecore/buckets/methods/itemBucketsFeatureMethods!");
            return methods;
        }

        public override object ComputeFieldValue(IIndexable indexable)
        {
            Item item = indexable as SitecoreIndexableItem;
            if (item == null)
            {
                return null;
            }

            Item itemBucketAncestor = GetItemBucketAncestor(item);
            if(itemBucketAncestor == null)
            {

                return null;
            }

            return NormalizeGuid(itemBucketAncestor.ID);
        }

        protected virtual Item GetItemBucketAncestor(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            if(IsItemBucket(item))
            {
                return null;
            }

            Item itemBucket = ItemBucketsFeatureMethods.GetItemBucket(item);
            if(!IsItemBucket(itemBucket))
            {
                return null;
            }

            return itemBucket;
        }

        protected virtual bool IsItemBucket(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            if (!ItemBucketsFeatureMethods.IsItemBucket(item))
            {
                return false;
            }

            return true;
        }

        protected virtual string NormalizeGuid(ID id)
        {
            return IdHelper.NormalizeGuid(id);
        }
    }
}

Not to go too much into details of the class above, it will only return an Item Bucket’s Sitecore.Data.ID as a string if the Item lives within an Item Bucket and is not itself an Item Bucket.

If the Item is not within an Item Bucket or is an Item Bucket, null is returned to the caller via the ComputeFieldValue() method.

I then created the following subclass of Sitecore.ContentSearch.SearchTypes.SearchResultItem — this lives in Sitecore.ContentSearch.dll — in order to use the values in the index that the previous Computed Field Index classes returned for their storage in the search index:

using System.ComponentModel;

using Sitecore.ContentSearch;
using Sitecore.ContentSearch.Converters;
using Sitecore.ContentSearch.SearchTypes;
using Sitecore.Data;

namespace Sitecore.Sandbox.Buckets.ContentSearch.SearchTypes
{
    public class BucketedSearchResultItem : SearchResultItem
    {
        [IndexField("item_bucket_ancestor_id")]
        [TypeConverter(typeof(IndexFieldIDValueConverter))]
        public ID ItemBucketAncestorId { get; set; }

        [IndexField("is_bucketed")]
        public bool IsBucketed { get; set; }
    }
}

Now, we need a class to get the Bucketed Item count for an Item Bucket. I defined the following interface for class implementations that do just that:

using Sitecore.Data.Items;

namespace Sitecore.Sandbox.Buckets.Providers.Items
{
    public interface IBucketedItemsCountProvider
    {
        int GetBucketedItemsCount(Item itemBucket);
    }
}

I then created the following class that implements the interface above:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Xml;

using Sitecore.Configuration;
using Sitecore.ContentSearch;
using Sitecore.ContentSearch.Linq;
using Sitecore.ContentSearch.Linq.Utilities;
using Sitecore.ContentSearch.SearchTypes;
using Sitecore.ContentSearch.Utilities;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Xml;

using Sitecore.Sandbox.Buckets.ContentSearch.SearchTypes;

namespace Sitecore.Sandbox.Buckets.Providers.Items
{
    public class BucketedItemsCountProvider : IBucketedItemsCountProvider
    {
        protected IDictionary<string, ISearchIndex> SearchIndexMap { get; private set; }

        public BucketedItemsCountProvider()
        {
            SearchIndexMap = CreateNewSearchIndexMap();
        }

        protected virtual IDictionary<string, ISearchIndex> CreateNewSearchIndexMap()
        {
            return new Dictionary<string, ISearchIndex>();
        }

        protected virtual void AddSearchIndexMap(XmlNode configNode)
        {
            if(configNode == null)
            {
                return;
            }

            string databaseName = XmlUtil.GetAttribute("database", configNode, null);
            Assert.IsNotNullOrEmpty(databaseName, "The database attribute on the searchIndexMap configuration element cannot be null or the empty string!");
            Assert.ArgumentCondition(!SearchIndexMap.ContainsKey(databaseName), "database", "The searchIndexMap configuration element's database attribute values must be unique!");

            Database database = Factory.GetDatabase(databaseName);
            Assert.IsNotNull(database, string.Format("No database exists with the name of '{0}'! Make sure the database attribute on your searchIndexMap configuration element is set correctly!", databaseName));
            
            string searchIndexName = XmlUtil.GetAttribute("searchIndex", configNode, null);
            Assert.IsNotNullOrEmpty(searchIndexName, "The searchIndex attribute on the searchIndexMap configuration element cannot be null or the empty string!");

            ISearchIndex searchIndex = GetSearchIndex(searchIndexName);
            Assert.IsNotNull(searchIndex, string.Format("No search index exists with the name of '{0}'! Make sure the searchIndex attribute on your searchIndexMap configuration element is set correctly", searchIndexName));

            SearchIndexMap.Add(databaseName, searchIndex);
        }

        public virtual int GetBucketedItemsCount(Item bucketItem)
        {
            Assert.ArgumentNotNull(bucketItem, "bucketItem");

            ISearchIndex searchIndex = GetSearchIndex();
            using (IProviderSearchContext searchContext = searchIndex.CreateSearchContext())
            {
                var predicate = GetSearchPredicate<BucketedSearchResultItem>(bucketItem.ID);
                IQueryable<SearchResultItem> query = searchContext.GetQueryable<BucketedSearchResultItem>().Filter(predicate);
                SearchResults<SearchResultItem> results = query.GetResults();
                return results.Count();
            }
        }

        protected virtual ISearchIndex GetSearchIndex()
        {
            string databaseName = GetContentDatabaseName();
            Assert.IsNotNullOrEmpty(databaseName, "The GetContentDatabaseName() method cannot return null or the empty string!");
            Assert.ArgumentCondition(SearchIndexMap.ContainsKey(databaseName), "databaseName", string.Format("There is no ISearchIndex instance mapped to the database: '{0}'!", databaseName));
            return SearchIndexMap[databaseName];
        }

        protected virtual string GetContentDatabaseName()
        {
            Database database = Context.ContentDatabase ?? Context.Database;
            Assert.IsNotNull(database, "Argggggh! There's no content database! Houston, we have a problem!");
            return database.Name;
        }

        protected virtual ISearchIndex GetSearchIndex(string searchIndexName)
        {
            Assert.ArgumentNotNullOrEmpty(searchIndexName, "searchIndexName");
            return ContentSearchManager.GetIndex(searchIndexName);
        }

        protected virtual Expression<Func<TSearchResultItem, bool>> GetSearchPredicate<TSearchResultItem>(ID itemBucketId) where TSearchResultItem : BucketedSearchResultItem
        {
            Assert.ArgumentCondition(!ID.IsNullOrEmpty(itemBucketId), "itemBucketId", "itemBucketId cannot be null or empty!");
            var predicate = PredicateBuilder.True<TSearchResultItem>();
            predicate = predicate.And(item => item.ItemBucketAncestorId == itemBucketId);
            predicate = predicate.And(item => item.IsBucketed);
            return predicate;
        }
    }
}

Ok, so what’s going on in the class above? The AddSearchIndexMap() method is called by the Sitecore Configuration Factory to add database-to-search-index mappings — have a look at the patch configuration file further below. The code is looking up the appropriate search index for the content/context database.

The GetBucketedItemsCount() method gets the “predicate” from the GetSearchPredicate() method which basically says “Hey, I want an Item that has an ancestor Item Bucket Sitecore.Data.ID which is the same as the Sitecore.Data.ID passed to the method, and also this Item should be bucketed”.

The GetBucketedItemsCount() method then employs the Sitecore.ContentSearch API to get the result-set of the Items for the query, and returns the count of those Items.

Just as Commands, DataViews in Sitecore are instantiated by the CreateObject() method on MainUtil. I want to utilize the Sitecore Configuration Factory instead so that my nested configuration elements are instantiated and injected into my custom DataView. I built the following interface to make that possible:

using System.Collections;

using Sitecore.Collections;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Globalization;

namespace Sitecore.Sandbox.Web.UI.HtmlControls.DataViews
{
    public interface IDataViewBaseExtender
    {
        void FilterItems(ref ArrayList children, string filter);

        void GetChildItems(ItemCollection items, Item item);

        Database GetDatabase();

        Item GetItemFromID(string id, Language language, Version version);

        Item GetParentItem(Item item);

        bool HasChildren(Item item, string filter);

        void Initialize(string parameters);

        bool IsAncestorOf(Item ancestor, Item item);

        void SortItems(ArrayList children, string sortBy, bool sortAscending);
    }
}

All of the methods in the above interface correspond to virtual methods defined on the Sitecore.Web.UI.HtmlControl.DataViewBase class in Sitecore.Kernel.dll.

I then built the following abstract class which inherits from any DataView class that inherits from Sitecore.Web.UI.HtmlControl.DataViewBase:

using System.Collections;

using Sitecore.Collections;
using Sitecore.Configuration;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Globalization;
using Sitecore.Web.UI.HtmlControls;

namespace Sitecore.Sandbox.Web.UI.HtmlControls.DataViews
{
    public abstract class ExtendedDataView<TDataView> : DataViewBase where TDataView : DataViewBase
    {
        protected IDataViewBaseExtender DataViewBaseExtender { get; private set; }

        protected ExtendedDataView()
        {
            DataViewBaseExtender = GetDataViewBaseExtender();
            EnsureDataViewBaseExtender();
        }

        protected virtual IDataViewBaseExtender GetDataViewBaseExtender()
        {
            string configPath = GetDataViewBaseExtenderConfigPath();
            Assert.IsNotNullOrEmpty(configPath, "GetDataViewBaseExtenderConfigPath() cannot return null or the empty string!");
            IDataViewBaseExtender dataViewBaseExtender = Factory.CreateObject(configPath, false) as IDataViewBaseExtender;
            Assert.IsNotNull(dataViewBaseExtender, string.Format("the IDataViewBaseExtender instance was not defined properly in '{0}'!", configPath));
            return dataViewBaseExtender;
        }

        protected abstract string GetDataViewBaseExtenderConfigPath();

        protected virtual void EnsureDataViewBaseExtender()
        {
            Assert.IsNotNull(DataViewBaseExtender, "GetDataViewBaseExtender() cannot return a null IDataViewBaseExtender instance!");
        }

        protected override void FilterItems(ref ArrayList children, string filter)
        {
            DataViewBaseExtender.FilterItems(ref children, filter);
        }

        protected override void GetChildItems(ItemCollection items, Item item)
        {
            DataViewBaseExtender.GetChildItems(items, item);
        }

        public override Database GetDatabase()
        {
            return DataViewBaseExtender.GetDatabase();
        }

        protected override Item GetItemFromID(string id, Language language, Version version)
        {
            return DataViewBaseExtender.GetItemFromID(id, language, version);
        }

        protected override Item GetParentItem(Item item)
        {
            return DataViewBaseExtender.GetParentItem(item);
        }

        public override bool HasChildren(Item item, string filter)
        {
            return DataViewBaseExtender.HasChildren(item, filter);
        }

        public override void Initialize(string parameters)
        {
            DataViewBaseExtender.Initialize(parameters);
        }

        public override bool IsAncestorOf(Item ancestor, Item item)
        {
            return DataViewBaseExtender.IsAncestorOf(ancestor, item);
        }

        protected override void SortItems(ArrayList children, string sortBy, bool sortAscending)
        {
            DataViewBaseExtender.SortItems(children, sortBy, sortAscending);
        }
    }
}

The GetDataViewBaseExtender() method gets the config path for the configuration-defined IDataViewBaseExtender — these IDataViewBaseExtender configuration definitions may or may not have nested configuration elements which will also be instantiated by the Sitecore Configuration Factory — from the abstract GetDataViewBaseExtenderConfigPath() method (subclasses must define this method).

The GetDataViewBaseExtender() then employs the Sitecore Configuration Factory to create this IDataViewBaseExtender instance, and return it to the caller (it’s being called in the class’ constructor).

If the instance is null, an exception is thrown.

All other methods in the above class delegate to methods with the same name and parameters on the IDataViewBaseExtender instance.

I then built the following subclass of the abstract class above:

using Sitecore.Web.UI.HtmlControls;

namespace Sitecore.Sandbox.Web.UI.HtmlControls.DataViews
{
    public class ExtendedMasterDataView : ExtendedDataView<MasterDataView>
    {
        protected override string GetDataViewBaseExtenderConfigPath()
        {
            return "extendedDataViews/extendedMasterDataView";
        }
    }
}

The above class is used for extending the MasterDataView in Sitecore.

It’s now time for the “real deal” DataView that does what we want: show the Bucketed Item counts for Item Buckets. The instance of the following class does just that:

using System.Collections;

using Sitecore.Buckets.Forms;
using Sitecore.Collections;
using Sitecore.Data;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Globalization;

using Sitecore.Sandbox.Buckets.Providers.Items;
using Sitecore.Sandbox.Buckets.Settings;
using Sitecore.Sandbox.Buckets.Util.Methods;
using Sitecore.Sandbox.Web.UI.HtmlControls.DataViews;

namespace Sitecore.Sandbox.Buckets.Forms
{
    public class BucketedItemsCountDataView : BucketDataView, IDataViewBaseExtender
    {
        protected IBucketsContentEditorSettings BucketsContentEditorSettings { get; set; }

        protected IItemBucketsFeatureMethods ItemBucketsFeatureMethods { get; set; }

        protected IBucketedItemsCountProvider BucketedItemsCountProvider { get; set; }

        protected string SingularBucketedItemsDisplayNameFormat { get; set; }

        protected string PluralBucketedItemsDisplayNameFormat { get; set; }

        void IDataViewBaseExtender.FilterItems(ref ArrayList children, string filter)
        {
            FilterItems(ref children, filter);
        }

        void IDataViewBaseExtender.GetChildItems(ItemCollection children, Item parent)
        {
            GetChildItems(children, parent);
        }

        protected override void GetChildItems(ItemCollection children, Item parent)
        {
            
            base.GetChildItems(children, parent);
            if(!ShouldShowBucketedItemsCount())
            {
                return;
            }

            for (int i = children.Count - 1; i >= 0; i--)
            {
                Item child = children[i];
                if (IsItemBucket(child))
                {
                    int count = GetBucketedItemsCount(child);
                    Item alteredItem = GetCountDisplayNameItem(child, count);
                    children.RemoveAt(i);
                    children.Insert(i, alteredItem);
                }
            }
        }

        protected virtual bool ShouldShowBucketedItemsCount()
        {
            Assert.IsNotNull(BucketsContentEditorSettings, "BucketsContentEditorSettings must be defined in configuration!");
            return BucketsContentEditorSettings.ShowBucketedItemsCount;
        }

        protected virtual bool IsItemBucket(Item item)
        {
            Assert.IsNotNull(ItemBucketsFeatureMethods, "ItemBucketsFeatureMethods must be set in configuration!");
            Assert.ArgumentNotNull(item, "item");
            return ItemBucketsFeatureMethods.IsItemBucket(item);
        }

        protected virtual int GetBucketedItemsCount(Item itemBucket)
        {
            Assert.IsNotNull(BucketedItemsCountProvider, "BucketedItemsCountProvider must be set in configuration!");
            Assert.ArgumentNotNull(itemBucket, "itemBucket");
            return BucketedItemsCountProvider.GetBucketedItemsCount(itemBucket);
        }

        protected virtual Item GetCountDisplayNameItem(Item item, int count)
        {
            FieldList fields = new FieldList();
            item.Fields.ReadAll();
            
            foreach (Field field in item.Fields)
            {
                fields.Add(field.ID, field.Value);
            }

            int bucketedCount = GetBucketedItemsCount(item);
            string displayName = GetItemNameWithBucketedCount(item, bucketedCount);
            ItemDefinition itemDefinition = new ItemDefinition(item.ID, displayName, item.TemplateID, ID.Null);
            return new Item(item.ID, new ItemData(itemDefinition, item.Language, item.Version, fields), item.Database) { RuntimeSettings = { Temporary = true } };
        }

        protected virtual string GetItemNameWithBucketedCount(Item item, int bucketedCount)
        {
            Assert.IsNotNull(SingularBucketedItemsDisplayNameFormat, "SingularBucketedItemsDisplayNameFormat must be set in configuration!");
            Assert.IsNotNull(PluralBucketedItemsDisplayNameFormat, "PluralBucketedItemsDisplayNameFormat must be set in configuration!");

            if (bucketedCount == 1)
            {
                return ReplaceTokens(SingularBucketedItemsDisplayNameFormat, item, bucketedCount);
            }

            return ReplaceTokens(PluralBucketedItemsDisplayNameFormat, item, bucketedCount);
        }

        protected virtual string ReplaceTokens(string format, Item item, int bucketedCount)
        {
            Assert.ArgumentNotNullOrEmpty(format, "format");
            Assert.ArgumentNotNull(item, "item");
            string replaced = format;
            replaced = replaced.Replace("$displayName", item.DisplayName);
            replaced = replaced.Replace("$bucketedCount", bucketedCount.ToString());
            return replaced;
        }

        Database IDataViewBaseExtender.GetDatabase()
        {
            return GetDatabase();
        }

        Item IDataViewBaseExtender.GetItemFromID(string id, Language language, Version version)
        {
            return GetItemFromID(id, language, version);
        }

        Item IDataViewBaseExtender.GetParentItem(Item item)
        {
            return GetParentItem(item);
        }

        bool IDataViewBaseExtender.HasChildren(Item item, string filter)
        {
            return HasChildren(item, filter);
        }

        void IDataViewBaseExtender.Initialize(string parameters)
        {
            Initialize(parameters);
        }

        bool IDataViewBaseExtender.IsAncestorOf(Item ancestor, Item item)
        {
            return IsAncestorOf(ancestor, item);
        }

        void IDataViewBaseExtender.SortItems(ArrayList children, string sortBy, bool sortAscending)
        {
            SortItems(children, sortBy, sortAscending);
        }
    }
}

You might be saying to yourself “Mike, what in the world is going on here?” 😉 Let me explain by starting with the GetChildItems() method.

The GetChildItems() method is used to build up the collection of child Items that display in the Content Tree when you expand a parent node. It does this by populating the ItemCollection instance passed to it.

The particular implementation above is delegating to the base class’ implementation to get the list of child Items for display in the Content Tree.

If we should not show the Bucketed Items count — this is determined by the ShouldShowBucketedItemsCount() method which just returns the boolean value set on the ShowBucketedItemsCount property of the injected IBucketsContentEditorSettings instance — the code just exits.

If we are to show the Bucketed Items count, we iterate over the ItemCollection collection and see if any of these child Items are Item Buckets — this is determined by the IsItemBucket() method.

If we find an Item Bucket, we get its count of Bucketed Items via the GetBucketedItemsCount() method which delegates to the GetBucketedItemsCount() method on the injected IBucketedItemsCountProvider instance.

Once we have the count, we call the GetCountDisplayNameItem() method which populates a FieldList collection with all of the fields defined on the Item Bucket; call the GetItemNameWithBucketedCount() method to get the new display name to show in the Content Tree — this method determines which display name format to use depending on whether we should use singular or pluralized messaging, and expands value on tokens via the ReplaceTokens() method — these tokens are defined in the patch configuration file below; creates an ItemDefinition instance so we can set the new display name; and returns a new Sitecore.Data.Items.Item instance to the caller.

No, don’t worry, we aren’t adding a new Item in the content tree but creating a fake “wrapper” of the real one, and replacing this in the ItemCollection.

We also have to fully implement the IDataViewBaseExtender interface. For most methods, I just delegate to the corresponding methods defined on the base class except for the IDataViewBaseExtender.GetChildItems() method which uses the GetChildItems() method defined above.

I then bridged everything above together via the following patch configuration file:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <buckets>
      <extendedCommands>
        <toggleBucketedItemsCountCommand type="Sitecore.Sandbox.Buckets.Shell.Framework.Commands.ToggleBucketedItemsCountCommand, Sitecore.Sandbox" singleInstance="on">
          <BucketsContentEditorSettings ref="buckets/settings/bucketsContentEditorSettings" />
        </toggleBucketedItemsCountCommand>
      </extendedCommands>
      <providers>
        <items>
          <bucketedItemsCountProvider type="Sitecore.Sandbox.Buckets.Providers.Items.BucketedItemsCountProvider, Sitecore.Sandbox" singleInstance="true">
            <searchIndexMaps hint="raw:AddSearchIndexMap">
              <searchIndexMap database="master" searchIndex="sitecore_master_index" />
              <searchIndexMap database="web" searchIndex="sitecore_web_index" />
            </searchIndexMaps>
          </bucketedItemsCountProvider>
        </items>
      </providers>
      <settings>
        <bucketsContentEditorSettings type="Sitecore.Sandbox.Buckets.Settings.BucketsContentEditorSettings, Sitecore.Sandbox" singleInstance="true">
          <ItemBucketsFeatureDeterminer ref="determiners/features/itemBucketsFeatureDeterminer"/>
          <Registry ref="registries/registry" />
          <ShowBucketedItemsCountRegistryKey>/Current_User/UserOptions.View.ShowBucketedItemsCount</ShowBucketedItemsCountRegistryKey>
        </bucketsContentEditorSettings>
      </settings>
    </buckets>
    <commands>
      <command name="contenteditor:togglebucketeditemscount" type="Sitecore.Sandbox.Shell.Framework.Commands.ExtendedConfigCommand, Sitecore.Sandbox" extendedCommandPath="buckets/extendedCommands/toggleBucketedItemsCountCommand" />
    </commands>
    <contentSearch>
      <indexConfigurations>
        <defaultLuceneIndexConfiguration>
          <fieldMap>
            <fieldNames>
              <field fieldName="item_bucket_ancestor_id" storageType="YES" indexType="TOKENIZED" vectorType="NO" boost="1f" type="System.String" settingType="Sitecore.ContentSearch.LuceneProvider.LuceneSearchFieldConfiguration, Sitecore.ContentSearch.LuceneProvider">
                <analyzer type="Sitecore.ContentSearch.LuceneProvider.Analyzers.LowerCaseKeywordAnalyzer, Sitecore.ContentSearch.LuceneProvider" />
              </field>
              <field fieldName="is_bucketed" storageType="YES" indexType="TOKENIZED" vectorType="NO" boost="1f" type="System.Boolean" settingType="Sitecore.ContentSearch.LuceneProvider.LuceneSearchFieldConfiguration, Sitecore.ContentSearch.LuceneProvider" />
            </fieldNames>
          </fieldMap>
          <documentOptions>
            <fields hint="raw:AddComputedIndexField">
              <field fieldName="item_bucket_ancestor_id">Sitecore.Sandbox.Buckets.ContentSearch.ComputedFields.ItemBucketAncestorId, Sitecore.Sandbox</field>
              <field fieldName="is_bucketed">Sitecore.Sandbox.Buckets.ContentSearch.ComputedFields.IsBucketed, Sitecore.Sandbox</field>
            </fields>
          </documentOptions>
        </defaultLuceneIndexConfiguration>
      </indexConfigurations>
    </contentSearch>
    <dataviews>
      <dataview name="Master">
        <patch:attribute name="assembly">Sitecore.Sandbox</patch:attribute>
        <patch:attribute name="type">Sitecore.Sandbox.Web.UI.HtmlControls.DataViews.ExtendedMasterDataView</patch:attribute>
      </dataview>
    </dataviews>
    <extendedDataViews>
      <extendedMasterDataView type="Sitecore.Sandbox.Buckets.Forms.BucketedItemsCountDataView, Sitecore.Sandbox" singleInstance="true">
        <BucketsContentEditorSettings ref="buckets/settings/bucketsContentEditorSettings" />
        <ItemBucketsFeatureMethods ref="buckets/methods/itemBucketsFeatureMethods" />
        <BucketedItemsCountProvider ref="buckets/providers/items/bucketedItemsCountProvider" />
        <SingularBucketedItemsDisplayNameFormat>$displayName &lt;span style="font-style: italic; color: blue;"&gt;($bucketedCount bucketed item)&lt;span&gt;</SingularBucketedItemsDisplayNameFormat>
        <PluralBucketedItemsDisplayNameFormat>$displayName &lt;span style="font-style: italic; color: blue;"&gt;($bucketedCount bucketed items)&lt;span&gt;</PluralBucketedItemsDisplayNameFormat>
      </extendedMasterDataView>
    </extendedDataViews>
    <registries>
      <registry type="Sitecore.Sandbox.Web.UI.HtmlControls.Registries.Registry, Sitecore.Sandbox" singleInstance="true" />
    </registries>
  </sitecore>
</configuration>

bridge-collapse

Let’s see this in action:

bucketed-items-count-testing

As you can see, it is working as intended.

partay-hard

Magical, right?

magic

Well, not really — it just appears that way. 😉

magic-not-really

If you have any thoughts on this, please drop a comment.

Prevent Unbucketable Sitecore Items from Being Moved to Bucket Folders

If you’ve been reading my posts lately, you have probably noticed I’ve been having a ton of fun with Sitecore Item Buckets. I absolutely love this feature in Sitecore.

As a matter of, I love Item Buckets so much, I’m doing a presentation on them just next week at the Greater Cincinnati Sitecore Users Group. If you’re in the neighborhood, stop by — even if it’s only to say “Hello”.

Anyways, back to the post.

I noticed the following grey box on the Items Buckets page on the Sitecore Documentation site:

item-buckets-unbucketable-import

This got me thinking: why can’t we build something in Sitecore to prevent this from happening in the first place?

In other words, why can’t we just say “sorry, you can’t move an unbucketable Item into a bucket folder”?

nope

So, that’s what I decided to do — build a solution that prevents this from happening. Let’s have a look at what I came up with.

I first created the following interface for classes whose instances will move a Sitecore item to a destination Item:

using Sitecore.Data.Items;

namespace Sitecore.Sandbox.Utilities.Items.Movers
{
    public interface IItemMover
    {
        bool DisableSecurity { get; set; }

        bool ShouldBeMoved(Item item, Item destination);

        void Move(Item item, Item destination);
    }
}

I then defined the following class which implements the interface above:

using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.SecurityModel;

namespace Sitecore.Sandbox.Utilities.Items.Movers
{
    public class ItemMover : IItemMover
    {
        public bool DisableSecurity { get; set; }
        
        public virtual bool ShouldBeMoved(Item item, Item destination)
        {
            return item != null && destination != null;
        }

        public virtual void Move(Item item, Item destination)
        {
            if (!ShouldBeMoved(item, destination))
            {
                return;
            }

            if(DisableSecurity)
            {
                MoveWithoutSecurity(item, destination);
                return;
            }

            MoveWithoutSecurity(item, destination);
        }

        protected virtual void MoveWithSecurity(Item item, Item destination)
        {
            Assert.ArgumentNotNull(item, "item");
            Assert.ArgumentNotNull(destination, "destination");
            item.MoveTo(destination);
        }

        protected virtual void MoveWithoutSecurity(Item item, Item destination)
        {
            Assert.ArgumentNotNull(item, "item");
            Assert.ArgumentNotNull(destination, "destination");
            using (new SecurityDisabler())
            {
                item.MoveTo(destination);
            }
        }
    }
}

Callers of the above code can move an Item from one location to another with/without Sitecore security in place.

The ShouldBeMoved() above is basically a stub that will allow subclasses to define their own rules on whether an Item should be moved, depending on whatever rules must be met.

I then defined the following subclass of the class above which has its own rules on whether an Item should be moved (i.e. move this unbucketable Item out of a bucket folder if makes its way there):

using Sitecore.Data.Items;
using Sitecore.Diagnostics;

using Sitecore.Sandbox.Buckets.Util.Methods;
using Sitecore.Sandbox.Utilities.Items.Movers;

namespace Sitecore.Sandbox.Buckets.Util.Items.Movers
{
    public class UnbucketableItemMover : ItemMover
    {
        protected IItemBucketsFeatureMethods ItemBucketsFeatureMethods { get; set; }

        public override bool ShouldBeMoved(Item item, Item destination)
        {
            return base.ShouldBeMoved(item, destination)
                    && !IsItemBucketable(item)
                    && IsItemInBucket(item)
                    && !IsItemBucketFolder(item)
                    && IsItemBucketFolder(item.Parent)
                    && IsItemBucket(destination);
        }

        protected virtual bool IsItemBucketable(Item item)
        {
            EnsureItemBucketFeatureMethods();
            Assert.ArgumentNotNull(item, "item");
            return ItemBucketsFeatureMethods.IsItemBucketable(item);
        }

        protected virtual bool IsItemInBucket(Item item)
        {
            EnsureItemBucketFeatureMethods();
            Assert.ArgumentNotNull(item, "item");
            return ItemBucketsFeatureMethods.IsItemContainedWithinBucket(item);
        }

        protected virtual bool IsItemBucketFolder(Item item)
        {
            EnsureItemBucketFeatureMethods();
            Assert.ArgumentNotNull(item, "item");
            return ItemBucketsFeatureMethods.IsItemBucketFolder(item);
        }

        protected virtual bool IsItemBucket(Item item)
        {
            EnsureItemBucketFeatureMethods();
            Assert.ArgumentNotNull(item, "item");
            return ItemBucketsFeatureMethods.IsItemBucket(item);
        }

        protected virtual void EnsureItemBucketFeatureMethods()
        {
            Assert.IsNotNull(ItemBucketsFeatureMethods, "ItemBucketsFeatureMethods must be set in configuration!");
        }
    }
}

I’m injecting an instance of an IItemBucketsFeatureMethods class — this interface and its implementation are defined in my previous post; go have a look if you have not read that post so you can be familiar with the IItemBucketsFeatureMethods code — via the Sitecore Configuration Factory which contains common methods I am using in my Item Bucket code solutions (I will be using this in future posts).

The ShouldBeMoved() method basically says that an Item can only be moved when the Item and destination passed aren’t null — this is defined on the base class’ ShouldBeMoved() method; the Item isn’t bucketable; the Item is already in an Item Bucket; the Item isn’t a Bucket Folder; the Item’s parent Item is a Bucket Folder; and the destination is an Item Bucket.

Yes, the above sounds a bit confusing though there is a reason for it — I want to take an unbucketable Item out of a Bucket Folder and move it directly under the Item Bucket instead.

I then created the following class which contains methods that will serve as “item:moved” event handlers:

using System;
using System.Collections.Generic;

using Sitecore.Data;
using Sitecore.Data.Events;
using Sitecore.Data.Items;
using Sitecore.Events;

using Sitecore.Sandbox.Buckets.Util.Methods;
using Sitecore.Sandbox.Utilities.Items.Movers;

namespace Sitecore.Sandbox.Buckets.Events.Items.Move
{
    public class RemoveFromBucketFolderIfNotBucketableHandler
    {
        protected static SynchronizedCollection<ID> ItemsBeingProcessed { get; set; }

        protected IItemBucketsFeatureMethods ItemBucketsFeatureMethods { get; set; }

        protected IItemMover UnbucketableItemMover { get; set; }

        protected void OnItemMoved(object sender, EventArgs args)
        {
            Item item = GetItem(args);
            RemoveFromBucketFolderIfNotBucketable(item);
        }

        static RemoveFromBucketFolderIfNotBucketableHandler()
        {
            ItemsBeingProcessed = new SynchronizedCollection<ID>();
        }

        protected virtual Item GetItem(EventArgs args)
        {
            if (args == null)
            {
                return null;
            }

            return Event.ExtractParameter(args, 0) as Item;
        }

        protected void OnItemMovedRemote(object sender, EventArgs args)
        {
            Item item = GetItemRemote(args);
            RemoveFromBucketFolderIfNotBucketable(item);
        }

        protected virtual Item GetItemRemote(EventArgs args)
        {
            ItemMovedRemoteEventArgs remoteArgs = args as ItemMovedRemoteEventArgs;
            if (remoteArgs == null)
            {
                return null;
            }

            return remoteArgs.Item;
        }

        protected virtual void RemoveFromBucketFolderIfNotBucketable(Item item)
        {
            if(item == null)
            {
                return;
            }
            
            Item itemBucket = GetItemBucket(item);
            if (itemBucket == null)
            {
                return;
            }

            if(!ShouldBeMoved(item, itemBucket))
            {
                return;
            }

            AddItemBeingProcessed(item);
            MoveUnderItemBucket(item, itemBucket);
            RemoveItemBeingProcessed(item);
        }

        protected virtual bool IsItemBeingProcessed(Item item)
        {
            if (item == null)
            {
                return false;
            }

            return ItemsBeingProcessed.Contains(item.ID);
        }

        protected virtual void AddItemBeingProcessed(Item item)
        {
            if (item == null)
            {
                return;
            }

            ItemsBeingProcessed.Add(item.ID);
        }

        protected virtual void RemoveItemBeingProcessed(Item item)
        {
            if (item == null)
            {
                return;
            }

            ItemsBeingProcessed.Remove(item.ID);
        }

        protected virtual Item GetItemBucket(Item item)
        {
            if(ItemBucketsFeatureMethods == null || item == null)
            {
                return null;
            }

            return ItemBucketsFeatureMethods.GetItemBucket(item);
        }

        protected virtual bool ShouldBeMoved(Item item, Item itemBucket)
        {
            if(UnbucketableItemMover == null)
            {
                return false;
            }

            return UnbucketableItemMover.ShouldBeMoved(item, itemBucket);
        }

        protected virtual void MoveUnderItemBucket(Item item, Item itemBucket)
        {
            if (UnbucketableItemMover == null)
            {
                return;
            }

            UnbucketableItemMover.Move(item, itemBucket);
        }
    }
}

Both the OnItemMoved() and OnItemMovedRemote() methods extract the moved Item from their specific methods for getting the Item from the EventArgs instance. If that Item is null, the code exits.

Both methods pass their Item instance to the RemoveFromBucketFolderIfNotBucketable() method which ultimately attempts to grab an Item Bucket ancestor of the Item via the GetItemBucket() method. If no Item Bucket instance is returned, the code exits.

If an Item Bucket was found, the RemoveFromBucketFolderIfNotBucketable() method ascertains whether the Item should be moved — it makes a call to the ShouldBeMoved() method which just delegates to the IItemMover instance injected in via the Sitecore Configuration Factory (have a look at the patch configuration file below).

If the Item should not be moved, then the code exits.

If it should be moved, it is then passed to the MoveUnderItemBucket() method which delegates to the Move() method on the IItemMover instance.

You might be asking “Mike, what’s up with the ItemsBeingProcessed SynchronizedCollection of Item IDs?” I’m using this collection to maintain which Items are currently being moved so we don’t have racing conditions in code.

You might be thinking “Great, we’re done!”

no

We can’t just move an Item from one destination to another, especially when the user selected the first destination. We should let the user know that we will need to move the Item as it is unbucketable. Let’s not be evil.

evil

I created the following class whose Process() method will serve as a custom processor for both the <uiDragItemTo> and <uiMoveItems> pipelines of the Sitecore Client:

using System;
using System.Collections.Generic;
using System.Linq;

using Sitecore.Configuration;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Text;
using Sitecore.Web.UI.Sheer;

using Sitecore.Sandbox.Buckets.Util.Methods;

namespace Sitecore.Sandbox.Buckets.Shell.Framework.Pipelines.MoveItems
{
    public class ConfirmMoveOfUnbucketableItem
    {
        protected string ItemIdsParameterName { get; set; }

        protected IItemBucketsFeatureMethods ItemBucketsFeatureMethods { get; set; }

        protected string ConfirmationMessageFormat { get; set; }

        public void Process(ClientPipelineArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            IEnumerable<string> itemIds = GetItemIds(args);
            if (itemIds == null || !itemIds.Any() || itemIds.Count() > 1)
            {
                return;
            }
            
            string targetId = GetTargetId(args);
            if (string.IsNullOrWhiteSpace(targetId))
            {
                return;
            }

            Database database = GetDatabase(args);
            if (database == null)
            {
                return;
            }

            Item targetItem = GetItem(database, targetId);
            if (targetItem == null || !IsItemBucketOrIsItemInBucket(targetItem))
            {
                return;
            }

            Item item = GetItem(database, itemIds.First());
            if (item == null || IsItemBucketable(item))
            {
                return;
            }

            Item itemBucket = GetItemBucket(targetItem);
            if (itemBucket == null)
            {
                return;
            }

            SetTokenValues(args, item, itemBucket);
            ConfirmMove(args);
        }

        protected virtual IEnumerable<string> GetItemIds(ClientPipelineArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.Parameters, "args.Parameters");
            string itemIdsParameterName = GetItemIdsParameterName(args);
            Assert.IsNotNullOrEmpty(itemIdsParameterName, "GetItemIdParameterName() cannot return null or the empty string!");
            return new ListString(itemIdsParameterName, '|');
        }

        protected virtual string GetItemIdsParameterName(ClientPipelineArgs args)
        {
            Assert.IsNotNullOrEmpty(ItemIdsParameterName, "ItemIdParameterName must be set in configuration!");
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.Parameters, "args.Parameters");
            return args.Parameters[ItemIdsParameterName];
        }

        protected virtual string GetTargetId(ClientPipelineArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.Parameters, "args.Parameters");
            return args.Parameters["target"];
        }

        protected virtual Database GetDatabase(ClientPipelineArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.Parameters, "args.Parameters");
            return Factory.GetDatabase(args.Parameters["database"]);
        }

        protected virtual Item GetItem(Database database, string itemId)
        {
            Assert.ArgumentNotNull(database, "database");
            Assert.ArgumentNotNullOrEmpty(itemId, "itemId");
            try
            {
                return database.GetItem(itemId);

            }
            catch(Exception ex)
            {
                Log.Error(ToString(), ex, this);
            }

            return null;
        }
        
        protected virtual bool IsItemBucketOrIsItemInBucket(Item item)
        {
            EnsureItemBucketsFeatureMethods();
            Assert.ArgumentNotNull(item, "item");
            return IsItemBucket(item) || IsItemInBucket(item);
        }

        protected virtual bool IsItemBucket(Item item)
        {
            EnsureItemBucketsFeatureMethods();
            Assert.ArgumentNotNull(item, "item");
            return ItemBucketsFeatureMethods.IsItemBucket(item);
        }

        protected virtual bool IsItemInBucket(Item item)
        {
            EnsureItemBucketsFeatureMethods();
            Assert.ArgumentNotNull(item, "item");
            return ItemBucketsFeatureMethods.IsItemContainedWithinBucket(item);
        }

        protected virtual bool IsItemBucketable(Item item)
        {
            EnsureItemBucketsFeatureMethods();
            Assert.ArgumentNotNull(item, "item");
            return ItemBucketsFeatureMethods.IsItemBucketable(item);
        }

        protected virtual Item GetItemBucket(Item item)
        {
            EnsureItemBucketsFeatureMethods();
            Assert.ArgumentNotNull(item, "item");
            if(!ItemBucketsFeatureMethods.IsItemBucket(item))
            {
                return ItemBucketsFeatureMethods.GetItemBucket(item);
            }

            return item;
        }

        protected virtual void SetTokenValues(ClientPipelineArgs args, Item item, Item itemBucket)
        {
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.Parameters, "args.Parameters");
            Assert.ArgumentNotNull(item, "item");
            Assert.ArgumentNotNull(itemBucket, "itemBucket");
            args.Parameters["$itemName"] = item.Name;
            args.Parameters["$itemBucketName"] = itemBucket.Name;
            args.Parameters["$itemBucketFullPath"] = itemBucket.Paths.FullPath;
        }

        protected virtual void ConfirmMove(ClientPipelineArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            if(args.IsPostBack)
            {
                if (args.Result == "yes")
                {
                    ClearResult(args);
                    return;
                }

                if (args.Result == "no")
                {
                    args.AbortPipeline();
                    return;
                }
            }
            else
            {
                SheerResponse.Confirm(GetConfirmationMessage(args));
                args.WaitForPostBack();    
            }   
        }

        protected virtual void ClearResult(ClientPipelineArgs args)
        {
            args.Result = string.Empty;
            args.IsPostBack = false;
        }

        protected virtual string GetConfirmationMessage(ClientPipelineArgs args)
        {
            Assert.IsNotNullOrEmpty(ConfirmationMessageFormat, "ConfirmationMessageFormat must be set in configuration!");
            Assert.ArgumentNotNull(args, "args");
            return ReplaceTokens(ConfirmationMessageFormat, args);
        }

        protected virtual string ReplaceTokens(string messageFormat, ClientPipelineArgs args)
        {
            Assert.ArgumentNotNullOrEmpty(messageFormat, "messageFormat");
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.Parameters, "args.Parameters");
            
            string message = messageFormat;
            message = message.Replace("$itemName", args.Parameters["$itemName"]);
            message = message.Replace("$itemBucketName", args.Parameters["$itemBucketName"]);
            message = message.Replace("$itemBucketFullPath", args.Parameters["$itemBucketFullPath"]);
            return message;
        }

        protected virtual void EnsureItemBucketsFeatureMethods()
        {
            Assert.IsNotNull(ItemBucketsFeatureMethods, "ItemBucketsFeatureMethods must be set in configuration!");
        }
    }
}

The Process() method above gets the Item ID for the Item that is being moved; the Item ID for the destination Item — this is referred to as the “target” in the code above; gets the Database instance of where we are moving this Item; the instances of both the Item and target Item; determines if the Target Item is a Bucket Folder or an Item Bucket; determines if the Item is unbucketable; and then the Item Bucket (this could be the target Item).

If any of of the instances above are null, the code exits.

If the Item is unbucketable but is being moved to a Bucket Folder or Item Bucket, we prompt the user with a confirmation dialog asking him/her whether he/she should like to continue given that the Item will be moved directly under the Item Bucket.

If the user clicks the ‘Ok’ button, the Item is moved. Otherwise, the pipeline is aborted and the Item will not be moved at all.

I then pieced all of the above together via the following patch configuration file:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <buckets>
      <movers>
        <items>
          <unbucketableItemMover type="Sitecore.Sandbox.Buckets.Util.Items.Movers.UnbucketableItemMover, Sitecore.Sandbox" singleInstance="true">
            <DisableSecurity>true</DisableSecurity>
            <ItemBucketsFeatureMethods ref="buckets/methods/itemBucketsFeatureMethods" />
          </unbucketableItemMover>
        </items>
      </movers>
    </buckets>
    <events>
      <event name="item:moved">
        <handler type="Sitecore.Sandbox.Buckets.Events.Items.Move.RemoveFromBucketFolderIfNotBucketableHandler, Sitecore.Sandbox" method="OnItemMoved">
          <ItemBucketsFeatureMethods ref="buckets/methods/itemBucketsFeatureMethods" />
          <UnbucketableItemMover ref="buckets/movers/items/unbucketableItemMover" />
        </handler>  
      </event>
      <event name="item:moved:remote">
        <handler type="Sitecore.Sandbox.Buckets.Events.Items.Move.RemoveFromBucketFolderIfNotBucketableHandler, Sitecore.Sandbox" method="OnItemMovedRemote">
          <ItemBucketsFeatureMethods ref="buckets/methods/itemBucketsFeatureMethods" />
          <UnbucketableItemMover ref="buckets/movers/items/unbucketableItemMover" />
        </handler>
      </event>
    </events>
    <processors>
      <uiDragItemTo>
        <processor patch:before="processor[@type='Sitecore.Buckets.Pipelines.UI.ItemDrag, Sitecore.Buckets' and @method='Execute']"
                   type="Sitecore.Sandbox.Buckets.Shell.Framework.Pipelines.MoveItems.ConfirmMoveOfUnbucketableItem, Sitecore.Sandbox" mode="on">
          <ItemIdsParameterName>id</ItemIdsParameterName>
          <ItemBucketsFeatureMethods ref="buckets/methods/itemBucketsFeatureMethods" />
          <ConfirmationMessageFormat>You are attempting to move the non-bucketable Item: $itemName to a bucket folder. If you continue, it will be moved directly under the Item Bucket: $itemBucketName ($itemBucketFullPath). Do you wish to continue?</ConfirmationMessageFormat>
        </processor>
      </uiDragItemTo>
      <uiMoveItems>
        <processor patch:before="processor[@type='Sitecore.Buckets.Pipelines.UI.ItemMove, Sitecore.Buckets' and @method='Execute']"
                     type="Sitecore.Sandbox.Buckets.Shell.Framework.Pipelines.MoveItems.ConfirmMoveOfUnbucketableItem, Sitecore.Sandbox" mode="on">
          <ItemIdsParameterName>items</ItemIdsParameterName>
          <ItemBucketsFeatureMethods ref="buckets/methods/itemBucketsFeatureMethods" />
          <ConfirmationMessageFormat>You are attempting to move the non-bucketable Item: $itemName to a bucket folder. If you continue, it will be moved directly under the Item Bucket: $itemBucketName ($itemBucketFullPath). Do you wish to continue?</ConfirmationMessageFormat>
        </processor>
      </uiMoveItems>
    </processors>
  </sitecore>
</configuration>

Let’s see how we did.

jenga-topple

Let’s move this unbucketable Item to an Item Bucket:

move-unbucketable-1

Yes, I’m sure I’m sure:

move-unbucketable-2

I was then prompted with the confirmation dialog as expected:

move-unbucketable-3

As you can see, the Item was placed directly under the Item Bucket:

move-unbucketable-4

If you have any thoughts on this, please drop a comment.

thats-all-folks

Prevent Duplicate Names of Bucketed Sitecore Items

In my previous post, I gave a solution that removes names of Bucket Folder Items from URLs in Sitecore, and also resolves those same URLs when they are called up in a browser.

However, that solution wasn’t complete — code is needed to ensure Bucketed Item names are unique given the solution assumes Bucketed Item names are unique (code in that solution uses the Sitecore.ContentSearch API to find an Item by name within an Item Bucket, and if there are two or more Items with the same name, only one will be returned — this will prevent the resolution of URLs for those other Bucketed page Items).

I decided to take up the challenge on continuing that solution over this previous weekend, and share what I built.

challenge

One thing to note: the solution that follows is not a complete solution for preventing duplicate Bucketed Item names as such a post could go on for ages — actually, I probably would still be writing the code for it. I leave the rest for you guys to do as a homework assignment. 😉

Cat-ate-my-homework-GIF

I first defined the following interface to centralize common methods I have been using in my Sitecore Item Buckets code (I will be reusing this same interface and its implementation in future blog posts):

using Sitecore.Data.Items;

namespace Sitecore.Sandbox.Buckets.Util.Methods
{
    public interface IItemBucketsFeatureMethods
    {
        bool IsItemBucketFolder(Item item);
        
        bool IsItemContainedWithinBucket(Item item);
        
        bool IsItemBucketable(Item item);
        
        Item GetItemBucket(Item item);

        bool IsItemBucket(Item item);

        bool HasBucketedItemWithName(Item itemBucket, string itemName);
    }
}

The following class implements the interface above:

using Sitecore.Buckets.Extensions;
using Sitecore.Buckets.Managers;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;

using Sitecore.Sandbox.Buckets.Providers.Items;

namespace Sitecore.Sandbox.Buckets.Util.Methods
{
    public class ItemBucketsFeatureMethods : IItemBucketsFeatureMethods
    {
        private IFindBucketedItemProvider FindBucketedItemProvider { get; set; }

        public virtual bool IsItemBucketFolder(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            return item.IsABucketFolder();
        }

        public virtual bool IsItemContainedWithinBucket(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            return BucketManager.IsItemContainedWithinBucket(item);
        }

        public virtual bool IsItemBucketable(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            return item.IsItemBucketable();
        }

        public virtual Item GetItemBucket(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            Item ancestor = item.GetParentBucketItemOrParent();
            if (!IsItemBucket(ancestor))
            {
                return null;
            }

            return ancestor;
        }

        public virtual bool IsItemBucket(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            return item.IsABucket();
        }

        public virtual bool HasBucketedItemWithName(Item itemBucket, string itemName)
        {
            EnsureFindBucketedItemProvider();
            Assert.ArgumentNotNull(itemBucket, "itemBucket");
            Assert.ArgumentNotNullOrEmpty(itemName, "itemName");
            Item item = FindBucketedItemProvider.FindBucketedItemByName(itemBucket, itemName);
            if(item == null)
            {
                return false;
            }

            return true;
        }

        protected virtual void EnsureFindBucketedItemProvider()
        {
            Assert.IsNotNull(FindBucketedItemProvider, "FindBucketedItemProvider must be set in configuration!");
        }
    }
}

I’m not going to go into details of the code above as it is self-explanatory.

However, I do want to call out that I am reusing the IFindBucketedItemProvider code from my previous post. I advise having a look at that code before moving forward.

I then defined the following class whose Process() method will serve as a processor of the <uiDragItemTo> and
<uiMoveItems> pipelines of the Sitecore Client:

using System;
using System.Collections.Generic;
using System.Linq;

using Sitecore.Configuration;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Text;
using Sitecore.Web.UI.Sheer;

using Sitecore.Sandbox.Buckets.Util.Methods;

namespace Sitecore.Sandbox.Buckets.Shell.Framework.Pipelines.MoveItems
{
    public class HandleDuplicateBucketedItemName
    {
        protected string ItemIdsParameterName { get; set; }

        protected IItemBucketsFeatureMethods ItemBucketsFeatureMethods { get; set; }

        protected string RenameItemMessage { get; set; }

        public void Process(ClientPipelineArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            IEnumerable<string> itemIds = GetItemIds(args);
            if (itemIds == null || !itemIds.Any() || itemIds.Count() > 1)
            {
                return;
            }
            
            string targetId = GetTargetId(args);
            if (string.IsNullOrWhiteSpace(targetId))
            {
                return;
            }

            Database database = GetDatabase(args);
            if (database == null)
            {
                return;
            }

            Item targetItem = GetItem(database, targetId);
            if (targetItem == null)
            {
                return;
            }

            Item item = GetItem(database, itemIds.First());
            if (item == null)
            {
                return;
            }

            Item itemBucket = GetItemBucket(targetItem);
            if (itemBucket == null || !HasBucketedItemWithName(itemBucket, item.Name))
            {
                return;
            }

            PromptRenameItem(args, item);
        }

        protected virtual IEnumerable<string> GetItemIds(ClientPipelineArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.Parameters, "args.Parameters");
            string itemIdsParameterName = GetItemIdsParameterName(args);
            Assert.IsNotNullOrEmpty(itemIdsParameterName, "GetItemIdParameterName() cannot return null or the empty string!");
            return new ListString(itemIdsParameterName, '|');
        }

        protected virtual string GetItemIdsParameterName(ClientPipelineArgs args)
        {
            Assert.IsNotNullOrEmpty(ItemIdsParameterName, "ItemIdParameterName must be set in configuration!");
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.Parameters, "args.Parameters");
            return args.Parameters[ItemIdsParameterName];
        }

        protected virtual string GetTargetId(ClientPipelineArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.Parameters, "args.Parameters");
            return args.Parameters["target"];
        }

        protected virtual Database GetDatabase(ClientPipelineArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.Parameters, "args.Parameters");
            return Factory.GetDatabase(args.Parameters["database"]);
        }

        protected virtual Item GetItem(Database database, string itemId)
        {
            Assert.ArgumentNotNull(database, "database");
            Assert.ArgumentNotNullOrEmpty(itemId, "itemId");
            try
            {
                return database.GetItem(itemId);

            }
            catch(Exception ex)
            {
                Log.Error(ToString(), ex, this);
            }

            return null;
        }

        protected virtual Item GetItemBucket(Item item)
        {
            EnsureItemBucketsFeatureMethods();
            Assert.ArgumentNotNull(item, "item");
            if(!ItemBucketsFeatureMethods.IsItemBucket(item))
            {
                return ItemBucketsFeatureMethods.GetItemBucket(item);
            }

            return item;
        }

        protected virtual bool HasBucketedItemWithName(Item itemBucket, string bucketedItemName)
        {
            EnsureItemBucketsFeatureMethods();
            Assert.ArgumentNotNull(itemBucket, "itemBucket");
            Assert.ArgumentNotNullOrEmpty(bucketedItemName, "bucketedItemName");
            return ItemBucketsFeatureMethods.HasBucketedItemWithName(itemBucket, bucketedItemName);
        }

        protected virtual void PromptRenameItem(ClientPipelineArgs args, Item item)
        {
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(item, "item");
            if (args.IsPostBack)
            {
                if(!args.HasResult)
                {
                    args.AbortPipeline();
                    return;
                }

                GetProposedValidItemName(args.Result);
                RenameItem(item, GetProposedValidItemName(args.Result));
                ClearResult(args);
            }
            else
            {
                SheerResponse.Input(GetRenameItemMessage(), string.Empty);
                args.WaitForPostBack();    
            }   
        }

        protected virtual void ClearResult(ClientPipelineArgs args)
        {
            args.Result = string.Empty;
            args.IsPostBack = false;
        }

        protected virtual string GetProposedValidItemName(string itemName)
        {
            Assert.ArgumentNotNull(itemName, "itemName");
            return ItemUtil.ProposeValidItemName(itemName);
        }

        protected virtual void RenameItem(Item item, string name)
        {
            Assert.ArgumentNotNull(item, "item");
            Assert.ArgumentNotNullOrEmpty(name, "name");
            using (new EditContext(item, false, true))
            {
                item.Name = name;
            }
        }

        protected virtual string GetRenameItemMessage()
        {
            Assert.ArgumentNotNull(RenameItemMessage, "RenameItemMessage must be set in configuration!");
            return RenameItemMessage;
        }

        protected virtual void EnsureItemBucketsFeatureMethods()
        {
            Assert.IsNotNull(ItemBucketsFeatureMethods, "ItemBucketsFeatureMethods must be set in configuration!");
        }
    }
}

The Process() method above basically gets the Item IDs of the Items that are moving from the Parameters collection on the arguments object passed to it — these are stored under different keys in the Parameters collection by the <uiDragItemTo> and <uiMoveItems> pipelines, so I’m letting the Sitecore Configuration Factory pass in the name of that parameter for each (see the patch configuration file below).

If more than one Item ID is returned, the code exits.

The Process() method then gets the target Item’s ID; the Database instance; the target Item; and the instance of the Item we are moving. If any of these are null, it exits.

The code then attempts to get the Item Bucket for the target Item — this is done via the GetItemBucket() method which just delegates to the GetItemBucket() method on the IItemBucketsFeatureMethods instance, or returns the passed Item if it is an Item Bucket. If the returned Item is null, the code exits.

The Process() method then calls the HasBucketedItemWithName() — this method just makes a call to a method with the same name on the ItemBucketsFeatureMethods instance — to see if there is another Bucketed Item within the Item Bucket with the same name. If one is not found, we prompt the user for a new Item name, and rename the Item if one is supplied. If no Item name is supplied, we abort the pipeline completely to prevent the user from moving forward.

I do want to highlight one more thing. the RenameItem() method uses a Sitecore.Data.Items.EditContext instance when changing the Item name. I decided to use an instance of this class to have this change be silent and not log any update statistics as this was causing the Item to become un-bucketable after it was moved (no clue as to why this would happen).

I then plugged-in all of the code above via the following patch configuration file:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <buckets>
      <methods>
        <itemBucketsFeatureMethods type="Sitecore.Sandbox.Buckets.Util.Methods.ItemBucketsFeatureMethods, Sitecore.Sandbox">
          <FindBucketedItemProvider ref="buckets/providers/items/findBucketedItemProvider" />
        </itemBucketsFeatureMethods>
      </methods>
    </buckets>
    <processors>
      <uiDragItemTo>
        <processor patch:before="processor[@type='Sitecore.Buckets.Pipelines.UI.ItemDrag, Sitecore.Buckets' and @method='Execute']"
                   type="Sitecore.Sandbox.Buckets.Shell.Framework.Pipelines.MoveItems.HandleDuplicateBucketedItemName, Sitecore.Sandbox" mode="on">
          <ItemIdsParameterName>id</ItemIdsParameterName>
          <ItemBucketsFeatureMethods ref="buckets/methods/itemBucketsFeatureMethods" />
          <RenameItemMessage>Duplicate bucketed Item names are not allowed.  Please enter in a new name for the item:</RenameItemMessage>
        </processor>
      </uiDragItemTo>
      <uiMoveItems>
        <processor patch:before="processor[@type='Sitecore.Buckets.Pipelines.UI.ItemMove, Sitecore.Buckets' and @method='Execute']"
                     type="Sitecore.Sandbox.Buckets.Shell.Framework.Pipelines.MoveItems.HandleDuplicateBucketedItemName, Sitecore.Sandbox" mode="on">
          <ItemIdsParameterName>items</ItemIdsParameterName>
          <ItemBucketsFeatureMethods ref="buckets/methods/itemBucketsFeatureMethods" />
          <RenameItemMessage>Duplicate bucketed Item names are not allowed.  Please enter in a new name for the item:</RenameItemMessage>
        </processor>
      </uiMoveItems>
    </processors>
  </sitecore>
</configuration>

plugged-in

Let’s take this for a spin.

Let’s test this out by dragging an Item to an Item Bucket that has a bucketed Item with the same name:

unique-name-move-1

Of course, I do:

unique-name-move-2

I gave it a unique name:

unique-name-move-3

As you can see, the Item was renamed and then moved:

unique-name-move-4

One thing to note: the Item will not be moved if the user clicks the ‘Cancel’ button in the dialog that asks for a new name.

Moreover, the above code only works when moving Items in the Sitecore Client. These pipeline processors will not run when moving Items via Sitecore API code (you’ll have to tap into one of the “move” related events, instead).

I’m going to omit sharing my testing of the “Move To” piece of the code above as it is using the same code. Trust me, it works. 😉

If you have any thoughts on this, please drop a comment.

Omit Sitecore Bucket Folder Item Names from Page Item URLs

In my two previous posts — this post and this post — I used the Sitecore Rules Engine to determine how bucket folders in the Item Buckets feature should be constructed.

I love having the freedom and flexibility to be able to do this.

However, depending on how you generate these folder structures, you might end up with some pretty yucky — ahem, I mean “interesting” — URLs if you have Actions that generate nonsense bucket folder Item names for bucketed Items.

For example, in my previous post, I built a custom Action that reversed the Item ID of the bucketed Item to generated its bucket folder path:

bucketed-links-bucketed-item-page-bucket-folders-2

Yucky, right?

Yuck

The “out of the box” Item Buckets bucket-folder-structure-generating algorithm creates a nice structure based on when the bucketed Item was created, and this is more palatable when looking at it:

bucketed-links-bucketed-item-page-bucket-folders-1

However, we may not be able to always use this “out of the box” algorithm for whatever reason — who knows what requirements will make us do — so let’s explore some code that can clean up these yucky URLs.

clean-up

Let’s first tackle cleaning up the URL generated by the “out of the box” Sitecore.Links.LinkProvider class.

I decided to implement a custom Sitecore pipeline to clean up the URL generated by the GetItemUrl() of the LinkProvider class, and will call this pipeline from a subclass of it (this code is further down in this post).

If you’re going to create a custom pipeline, you’ll need an arguments object for it — this is technically known as a Parameter Object but I will stick with the name “arguments object” and “arguments class” for the class that these objects are instantiated from throughout this post.

The following class is the arguments class for my custom pipeline:

using System;

using Sitecore.Data.Items;
using Sitecore.Links;
using Sitecore.Pipelines;

namespace Sitecore.Sandbox.Buckets.Pipelines.GetBucketedItemLinkUrl
{
    public class GetBucketedItemLinkUrlArgs : PipelineArgs
    {
        public Func<Item, UrlOptions, string> GetItemUrl { get; set; }

        public UrlOptions UrlOptions { get; set; }

        public Item ItemBucket { get; set; }

        public Item BucketedItem { get; set; }

        public string DefaultUrl { get; set; }

        public string BucketedItemUrl { get; set; }
    }
}

I defined the following abstract class which all processors of the custom pipeline must inherit:

namespace Sitecore.Sandbox.Buckets.Pipelines.GetBucketedItemLinkUrl
{
    public abstract class GetBucketedItemLinkUrlProcessor
    {
        public abstract void Process(GetBucketedItemLinkUrlArgs args);
    }
}

All subclasses must implement the abstract Process method above which takes in the arguments object with the class type defined above.

The following class whose instance is used as the first processor of the custom pipeline checks whether required property values are set on the arguments object passed to the pipeline:

using Sitecore.Diagnostics;

namespace Sitecore.Sandbox.Buckets.Pipelines.GetBucketedItemLinkUrl
{
    public class EnsureParameters : GetBucketedItemLinkUrlProcessor
    {
        public override void Process(GetBucketedItemLinkUrlArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            if(!CanProcess(args))
            {
                args.AbortPipeline();
            }
        }

        protected virtual bool CanProcess(GetBucketedItemLinkUrlArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            return args.GetItemUrl != null
                    && args.BucketedItem != null
                    && !string.IsNullOrWhiteSpace(args.DefaultUrl);
        }
    }
}

The BucketedItem, DefaultUrl — this is the “default” URL generated by the GetItemUrl() method on the “out of the box” LinkProvider class, and GetItemUrl — this is a “pointer” to the GetItemUrl method on the LinkProvider class — properties are required by the custom pipeline when initially called, and these are checked here.

This next class who instance is used for the second processor of the pipeline does some vetting of the bucketed Item passed on the arguments object:

using Sitecore.Data.Items;
using Sitecore.Diagnostics;

using Sitecore.Buckets.Extensions;
using Sitecore.Buckets.Managers;

namespace Sitecore.Sandbox.Buckets.Pipelines.GetBucketedItemLinkUrl
{
    public class InspectBucketedItem : GetBucketedItemLinkUrlProcessor
    {
        public override void Process(GetBucketedItemLinkUrlArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.BucketedItem, "args.BucketedItem");
            
            if (!IsItemContainedWithinBucket(args.BucketedItem) || !IsParentBucketFolder(args.BucketedItem))
            {
                args.AbortPipeline();
            }
        }

        protected virtual bool IsItemContainedWithinBucket(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            return BucketManager.IsItemContainedWithinBucket(item);
        }

        protected virtual bool IsParentBucketFolder(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            return item.Parent.IsABucketFolder();
        }
    }
}

The instance of the class above checks to see whether the bucketed Item is a descendant of an Item Bucket, and also sees if it is contained within a bucket folder. If one of these is not true, the pipeline is aborted.

The following class whose instance serves as the third processor of the custom pipeline gets the bucketed Item’s Item Bucket:

using Sitecore.Data.Items;
using Sitecore.Diagnostics;

using Sitecore.Buckets.Extensions;

namespace Sitecore.Sandbox.Buckets.Pipelines.GetBucketedItemLinkUrl
{
    public class SetItemBucket : GetBucketedItemLinkUrlProcessor
    {
        public override void Process(GetBucketedItemLinkUrlArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.BucketedItem, "args.BucketedItem");
            Item item = GetItemBucket(args.BucketedItem);
            if(!IsItemBucket(item))
            {
                args.AbortPipeline();
                return;
            }

            args.ItemBucket = item;
        }

        protected virtual Item GetItemBucket(Item bucketedItem)
        {
            Assert.ArgumentNotNull(bucketedItem, "bucketedItem");
            return bucketedItem.GetParentBucketItemOrParent();
        }

        protected virtual bool IsItemBucket(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            return item != null && item.IsABucket();
        }
    }
}

Code in the GetItemBucket() method uses the GetParentBucketItemOrParent() extension method of the Item class — this lives in the Sitecore.Buckets.Extensions namespace in Sitecore.Buckets.dll — to get the Item Bucket ancestor for the bucketed Item.

If the Item returned is not an Item Bucket — this check is done in the IsItemBucket() method via another Item class extension method, the IsABucket() method, which also lives in the same namespace as the extension method mentioned above — then the pipeline is aborted.

This next class whose instance serves as the last processor of the custom pipeline generates a URL without the bucket folder names in it:

using Sitecore.Diagnostics;

namespace Sitecore.Sandbox.Buckets.Pipelines.GetBucketedItemLinkUrl
{
    public class SetBucketedItemUrl : GetBucketedItemLinkUrlProcessor
    {
        public override void Process(GetBucketedItemLinkUrlArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.GetItemUrl, "args.GetItemUrlMethod");
            Assert.ArgumentNotNull(args.ItemBucket, "args.ItemBucket");
            Assert.ArgumentNotNullOrEmpty(args.DefaultUrl, "args.DefaultUrl");
            string bucketedItemUrl = GetBucketedItemUrl(args);
            if(string.IsNullOrWhiteSpace(bucketedItemUrl))
            {
                args.AbortPipeline();
                return;
            }

            args.BucketedItemUrl = bucketedItemUrl;
        }

        protected virtual string GetBucketedItemUrl(GetBucketedItemLinkUrlArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            Assert.ArgumentNotNull(args.GetItemUrl, "args.GetItemUrlMethod");
            Assert.ArgumentNotNull(args.ItemBucket, "args.ItemBucket");
            Assert.ArgumentNotNullOrEmpty(args.DefaultUrl, "args.DefaultUrl");
            string itemBucketUrl = args.GetItemUrl(args.ItemBucket, args.UrlOptions);
            if (string.IsNullOrWhiteSpace(itemBucketUrl))
            {
                return string.Empty;
            }

            string baseUrl = GetExtensionlessUrl(itemBucketUrl);
            string pageUrlPart = GetUrlPagePart(args.DefaultUrl);
            if (string.IsNullOrWhiteSpace(pageUrlPart))
            {
                return string.Empty;
            }

            return string.Join("/", baseUrl, pageUrlPart);
        }

        protected virtual string GetUrlPagePart(string url)
        {
            Assert.ArgumentNotNullOrEmpty(url, "url");
            int lastForwardSlashIndex = url.LastIndexOf("/");
            if (lastForwardSlashIndex < 0)
            {
                return string.Empty;
            }

            return url.Substring(lastForwardSlashIndex + 1);
        }

        protected virtual string GetExtensionlessUrl(string url)
        {
            Assert.ArgumentNotNullOrEmpty(url, "url");
            string extensionlessUrl = url;
            int lastDotIndex = extensionlessUrl.LastIndexOf(".");
            if (lastDotIndex < 0)
            {
                return extensionlessUrl;
            }

            return extensionlessUrl.Substring(0, lastDotIndex);
        }
    }
}

I’m not going to go too much into the details of all the code above. It is basically getting the web page name piece of the URL set in the DefaultUrl property on the arguments object, and appends it to the end of the URL of the Item Bucket without an extension (who uses extensions on URLs anyhow? ¯\_(ツ)_/¯ That’s like so 5 years ago. 😉 ).

The resulting URL is set in the BucketedItemUrl property of the arguments object.

Now that we have code to generate bucket-folder-less URLs, we need to use it. I built the following subclass of Sitecore.Links.LinkProvider which calls it:

using System.Collections.Specialized;

using Sitecore.Buckets.Extensions;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Links;
using Sitecore.Pipelines;

using Sitecore.Sandbox.Buckets.Pipelines.GetBucketedItemLinkUrl;

namespace Sitecore.Sandbox.Buckets.LinkProviders
{
    public class BucketedItemLinkProvider : LinkProvider
    {
        private string GetBucketedItemLinkUrlPipeline { get; set; }

        public override void Initialize(string name, NameValueCollection config)
        {
            Assert.ArgumentNotNullOrEmpty(name, "name");
            Assert.ArgumentNotNull(config, "config");
            base.Initialize(name, config);
            GetBucketedItemLinkUrlPipeline = config["getBucketedItemLinkUrlPipeline"];
        }

        public override string GetItemUrl(Item item, UrlOptions options)
        {
            string url = GetItemUrlFromBase(item, options);
            bool shouldGetBucketedItemUrl = !string.IsNullOrWhiteSpace(GetBucketedItemLinkUrlPipeline) 
                                                && !string.IsNullOrWhiteSpace(url) 
                                                && IsParentBucketFolder(item);
            if (!shouldGetBucketedItemUrl)
            {
                return url;
            }

            string bucketedItemUrl = GetBucketedItemUrl(item, options, url);
            if(string.IsNullOrWhiteSpace(bucketedItemUrl))
            {
                return url;
            }

            return bucketedItemUrl;
        }

        protected virtual bool IsParentBucketFolder(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            return item.Parent.IsABucketFolder();
        }

        protected virtual string GetBucketedItemUrl(Item bucketedItem, UrlOptions options, string defaultUrl)
        {
            GetBucketedItemLinkUrlArgs args = new GetBucketedItemLinkUrlArgs
            {
                GetItemUrl = ((someItem, urlOptions) => GetItemUrlFromBase(someItem, urlOptions)),
                UrlOptions = options,
                BucketedItem = bucketedItem,
                DefaultUrl = defaultUrl
            };

            CorePipeline.Run(GetBucketedItemLinkUrlPipeline, args);
            return args.BucketedItemUrl;
        }

        protected virtual string GetItemUrlFromBase(Item item, UrlOptions options)
        {
            return base.GetItemUrl(item, options);
        }
    }
}

I’ve extended the Initialize() method on the base LinkProvider class to read in the name of the custom pipeline (this is set in the patch configuration file further down in this post).

The overridden GetItemUrl() method grabs the URL generated by the same method on the base class for the passed Item. If the custom pipeline’s name is set; the generated URL isn’t null or empty; and the item lives in a bucket folder, the custom pipeline is called with the required parameters set on the arguments object.

If the custom pipeline generated a URL, it is returned to the caller. If not, the URL generated by the base class’ GetItemUrl() method is returned.

You might be thinking “Excellent, Mike! We have code that fixes the issue. Are we done yet?” Not so fast, dear reader — we need write code so Sitecore can resolve these bucket-folder-less URLs.

Since the URLs no longer contain the full path to the bucketed Item, we need a way to find this Item under the Item Bucket. I created the following interface for classes that can find a bucketed Item by name whose ancestor is the Item Bucket:

using Sitecore.Data.Items;

namespace Sitecore.Sandbox.Buckets.Providers.Items
{
    public interface IFindBucketedItemProvider
    {
        Item FindBucketedItemByName(Item bucketItem, string bucketedItemName);
    }
}

The following class implements the interface above:

using System;
using System.Linq;
using System.Linq.Expressions;

using Sitecore.Buckets.Util;
using Sitecore.ContentSearch;
using Sitecore.ContentSearch.Linq;
using Sitecore.ContentSearch.Linq.Utilities;
using Sitecore.ContentSearch.SearchTypes;
using Sitecore.ContentSearch.Utilities;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;

namespace Sitecore.Sandbox.Buckets.Providers.Items
{
    public class FindBucketedItemProvider : IFindBucketedItemProvider
    {
        private ID bucketFolderTemplateId;
        private ID BucketFolderTemplateId
        {
            get
            {
                if(ID.IsNullOrEmpty(bucketFolderTemplateId))
                {
                    bucketFolderTemplateId = GetBucketFolderTemplateId();
                }

                return bucketFolderTemplateId;
            }
        }

        private string PreviewSearchIndexName { get; set; }

        private string LiveSearchIndexName { get; set; }

        private ISearchIndex previewSearchIndex;
        private ISearchIndex PreviewSearchIndex
        {
            get
            {
                if (previewSearchIndex == null)
                {
                    previewSearchIndex = GetPreviewSearchIndex();
                }

                return previewSearchIndex;
            }
        }

        private ISearchIndex liveSearchIndex;
        private ISearchIndex LiveSearchIndex
        {
            get
            {
                if (liveSearchIndex == null)
                {
                    liveSearchIndex = GetLiveSearchIndex();
                }

                return liveSearchIndex;
            }
        }
       
        public virtual Item FindBucketedItemByName(Item bucketItem, string bucketedItemName)
        {
            Assert.ArgumentCondition(!ID.IsNullOrEmpty(BucketFolderTemplateId), "BucketFolderTemplateId", "GetBucketFolderTemplateId() cannot return a null or empty Item ID!");
            Assert.ArgumentNotNull(bucketItem, "bucketItem");
            Assert.ArgumentNotNull(bucketedItemName, "bucketedItemName");

            ISearchIndex searchIndex = GetSearchIndex();
            using (IProviderSearchContext searchContext = searchIndex.CreateSearchContext())
            {
                var predicate = GetSearchPredicate<SearchResultItem>(bucketItem.ID, bucketedItemName);
                IQueryable<SearchResultItem> query = searchContext.GetQueryable<SearchResultItem>().Filter(predicate);
                SearchResults<SearchResultItem> results = query.GetResults();
                if (results.Count() < 1)
                {
                    return null;
                }

                SearchHit<SearchResultItem> hit = results.Hits.First();
                return hit.Document.GetItem();
            }
        }

        protected virtual ISearchIndex GetSearchIndex()
        {
            if (Context.PageMode.IsPreview)
            {
                Assert.IsNotNull(PreviewSearchIndex, "PreviewSearchIndex is null. Double-check the SearchIndexName configuration setting!");
                return PreviewSearchIndex;
            }

            Assert.IsNotNull(LiveSearchIndex, "LiveSearchIndex is null. Double-check the SearchIndexName configuration setting!");
            return LiveSearchIndex;
        }

        protected virtual ISearchIndex GetPreviewSearchIndex()
        {
            Assert.IsNotNullOrEmpty(PreviewSearchIndexName, "PreviewSearchIndexName is empty. Double-check its configuration setting!");
            return GetSearchIndex(PreviewSearchIndexName);
        }

        protected virtual ISearchIndex GetLiveSearchIndex()
        {
            Assert.IsNotNullOrEmpty(LiveSearchIndexName, "LiveSearchIndexName is empty. Double-check its configuration setting!");
            return GetSearchIndex(LiveSearchIndexName);
        }

        protected virtual ISearchIndex GetSearchIndex(string searchIndexName)
        {
            Assert.ArgumentNotNullOrEmpty(searchIndexName, "searchIndexName");
            return ContentSearchManager.GetIndex(searchIndexName);
        }

        protected virtual Expression<Func<T, bool>> GetSearchPredicate<T>(ID bucketItemId, string bucketedItemName) where T : SearchResultItem
        {
            var predicate = PredicateBuilder.True<T>();
            predicate = predicate.And(item => item.Paths.Contains(bucketItemId));
            predicate = predicate.And(item => item.TemplateId != BucketFolderTemplateId);
            predicate = predicate.And(item => item.Parent != bucketItemId);
            predicate = predicate.And(item => item.ItemId != bucketItemId);
            predicate = predicate.And(item => string.Equals(item.Name, bucketedItemName, StringComparison.CurrentCultureIgnoreCase));
            return predicate;
        }

        protected virtual ID GetBucketFolderTemplateId()
        {
            return BucketConfigurationSettings.BucketTemplateId;
        }
    }
}

I’m leveraging the Sitecore.ContentSearch API in the class above to find a bucketed Item with a given name — this is passed as a parameter to the FindBucketedItemByName() method — which is a descendant of the passed Item Bucket.

The GetSearchPredicate() method builds up a “predicate” which basically says “hey, we need an Item that is a descendant of the Item Bucket who is not a bucket folder; isn’t a child of the Item Bucket; isn’t the Item Bucket itself; and has a certain name (though we are ignoring case here)”, and is used by the Sitecore.ContentSearch API code in the FindBucketedItemByName() method.

“Mike, what’s up with the two ISearchIndex instances on the above class?” I have defined two here: one for when we are in Preview mode — I’m using the master search index here — and the other for when we aren’t — this uses the web search index instead.

If results are found, we return the Item instance from the first result in the results collection to the caller.

“Mike, could there ever be multiple Items returned?” Yes, this could happen if we have more than one bucketed Item with the same name, and only the first one in the results collection will be returned. In a future blog post, I will share a solution which will enforce unique Item names for bucketed Items.

Now that we have code that can find a bucketed Item by name under an Item Bucket, we need a custom Item Resolver — this is just a custom <httpRequestBegin> pipeline processor — that uses an instance of the class above to set the context Item for these bucket-folder-less URLs.

The following class does just that:

using System;
using System.Collections.Generic;
using System.Linq;

using Sitecore.Buckets.Extensions;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Pipelines.HttpRequest;

using Sitecore.Sandbox.Buckets.Providers.Items;

namespace Sitecore.Sandbox.Buckets.Pipelines.HttpRequest
{
    public class BucketedItemResolver : HttpRequestProcessor
    {
        private List<string> TargetSites { get; set; }

        private IFindBucketedItemProvider FindBucketedItemProvider { get; set; }

        public BucketedItemResolver()
        {
            TargetSites = new List<string>();
        }

        public override void Process(HttpRequestArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            if(!ShouldProcess(args))
            {
                return;
            }

            StartProfilerOperation();
            string path = MainUtil.DecodeName(args.Url.ItemPath);
            if (string.IsNullOrWhiteSpace(path))
            {
                EndProfilerOperation();
                return;
            }

            int lastForwardSlashIndex = path.LastIndexOf("/");
            if (lastForwardSlashIndex < 0)
            {
                EndProfilerOperation();
                return;
            }

            string parentPath = path.Substring(0, lastForwardSlashIndex);
            Item parentItem = args.GetItem(parentPath);
            if(parentItem == null)
            {
                EndProfilerOperation();
                return;
            }

            if (!parentItem.IsABucket())
            {
                EndProfilerOperation();
                return;
            }
            
            string bucketedItemName = path.Substring(lastForwardSlashIndex + 1);
            Item bucketedItem = FindBucketedItemByName(parentItem, bucketedItemName);
            if(bucketedItem == null)
            {
                EndProfilerOperation();
                return;
            }

            Context.Item = bucketedItem;
            EndProfilerOperation();
        }

        protected virtual bool ShouldProcess(HttpRequestArgs args)
        {
            return Context.Item == null
                    && Context.Database != null
                    && IsTargetSite()
                    && !string.IsNullOrWhiteSpace(args.Url.ItemPath);
        }

        protected virtual bool IsTargetSite()
        {
            return Context.Site != null
                    && TargetSites != null
                    && TargetSites.Any(site => string.Equals(site, Context.Site.Name, StringComparison.CurrentCultureIgnoreCase));
        }

        protected virtual void StartProfilerOperation()
        {
            Profiler.StartOperation("Resolve current bucketed item.");
        }

        protected virtual void EndProfilerOperation()
        {
            Profiler.EndOperation();
        }

        protected virtual Item FindBucketedItemByName(Item bucketItem, string bucketedItemName)
        {
            Assert.IsNotNull(FindBucketedItemProvider, "IFindBucketedItemProvider must be set in configuration!");
            return FindBucketedItemProvider.FindBucketedItemByName(bucketItem, bucketedItemName);
        }
    }
}

Not to go too much into all of the code above, the Process() method basically determines whether it should move forward on processing the request.

When should it do that? If there isn’t already context Item set; the context Database is set; the request is being made in a targeted site — basically this is just a list of site names sourced in the patch configuration file below which is a list of websites we want this code to run in (we don’t want to this code to run in the “shell” website which is what the Sitecore Desktop and Content Editor use); and have an Item path, then the Process() method should continue.

Other code in the Process() method is extracting out the parent’s page’s Item path; grabs an instance of the parent Item; determines whether it is an Item Bucket — if it’s not, then the code exits; the name of the page Item from URL; and then passes the parent Item and Item name to the FindBucketedItemByName() method which delegates to the FindBucketedItemByName() method on the IFindBucketedItemProvider instance to find the bucketed Item.

If a bucketed Item was found, the Process() method sets this as the context Item. Otherwise, it just exits.

I’m also using profiling code in the above class just as can be seen in the “out of the box” Sitecore.Pipelines.HttpRequest.ItemResolver class — this lives in Sitecore.Kernel.dll.

I then super-glued all of the code above in the following patch configuration file:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <buckets>
      <providers>
        <items>
          <findBucketedItemProvider type="Sitecore.Sandbox.Buckets.Providers.Items.FindBucketedItemProvider, Sitecore.Sandbox">
            <PreviewSearchIndexName>sitecore_master_index</PreviewSearchIndexName>
            <LiveSearchIndexName>sitecore_web_index</LiveSearchIndexName>
          </findBucketedItemProvider>
        </items>
      </providers>
    </buckets>
    <linkManager>
      <providers>
        <add name="sitecore">
          <patch:attribute name="type">Sitecore.Sandbox.Buckets.LinkProviders.BucketedItemLinkProvider, Sitecore.Sandbox</patch:attribute>
          <patch:attribute name="getBucketedItemLinkUrlPipeline">getBucketedItemLinkUrl</patch:attribute>
        </add>
      </providers>
    </linkManager>
    <pipelines>
      <getBucketedItemLinkUrl>
        <processor type="Sitecore.Sandbox.Buckets.Pipelines.GetBucketedItemLinkUrl.EnsureParameters, Sitecore.Sandbox" />
        <processor type="Sitecore.Sandbox.Buckets.Pipelines.GetBucketedItemLinkUrl.InspectBucketedItem, Sitecore.Sandbox" />
        <processor type="Sitecore.Sandbox.Buckets.Pipelines.GetBucketedItemLinkUrl.SetItemBucket, Sitecore.Sandbox" />
        <processor type="Sitecore.Sandbox.Buckets.Pipelines.GetBucketedItemLinkUrl.SetBucketedItemUrl, Sitecore.Sandbox" />
      </getBucketedItemLinkUrl>
      <httpRequestBegin>
        <processor patch:before="processor[@type='Sitecore.Pipelines.HttpRequest.ItemResolver, Sitecore.Kernel']"
                   type="Sitecore.Sandbox.Buckets.Pipelines.HttpRequest.BucketedItemResolver, Sitecore.Sandbox">
          <TargetSites hint="list">
            <site>website</site>
          </TargetSites>
          <FindBucketedItemProvider ref="buckets/providers/items/findBucketedItemProvider" />
        </processor>  
      </httpRequestBegin>
    </pipelines>
  </sitecore>
</configuration>

Let’s see if this works.

Let’s insert some bucketed Item links into this Rich Text field on my home Item:

bucketed-links-insert-link-rtf

Let’s insert an internal link to this Item:

bucketed-links-insert-link-1

Let’s insert another link to this Item:

bucketed-links-insert-link-2

Let’s insert yet another internal link — let’s insert a link to this Item:

bucketed-links-insert-link-3

After publishing and navigating to my home page, I see this in the html for the links:

bucketed-links-html-rendered

After clicking on the first link, I’m brought to the bucketed Item but have a look at the URL:

bucketed-links-bucketed-item-page

As you can see it worked!

When I finally got this working, I found myself doing a happy dance:

techno-chicken

techno-chickens

If you have any thoughts on this, please drop a comment.

A Sitecore Item Buckets GutterRenderer to Convey Which Algorithm Was Used for Creating Bucket Folders

In my previous post, I gave a solution which I leverages the Sitecore Rules Engine to create a custom Item Buckets folder structure for storing bucketable Items.

Last night, I had a thought: what if you needed to know which “algorithm” created a given bucket folder structure for an Item Bucket? How could we go about conveying this type of information?

Immediately, the Sitecore Gutter came to mind — it’s a great way to communicate this type of information visually.

mind-outta-gutter

Before I move forward on the solution I started last night and completed today, let me explain what the Sitecore Gutter is, just in case you are unfamiliar with this feature.

The Sitecore Gutter lives here in the Content Editor:

smart-bucket-gutter-sitecore-gutter

If you right-click in this area, you get a context menu to turn on/off Gutter indicators:

smart-gutter-sitecore-gutter-context-menu

I turned on the Item Buckets Gutter indicator, and now can see which Items are Buckets:

smart-gutter-turn-on-buckets-gutter

There is a huge body of blog posts out on the interwebs which give examples on adding to the Sitecore Gutter. Here are a few posts from some fellow Sitecore MVPs which I highly recommend reading (in order of publish date):

I also wrote a post on how to add to the Sitecore Gutter using the Sitecore PowerShell Extensions module. I recommend having a look at that as well. 😉

Now that we are well-versed — or “wicked smaht” as we Bostonians would alternatively say — on what the Sitecore Gutter is, let’s move on to the solution I came up with.

three-stooges-ejoomicated

Just a “heads up”: there is a lot of code in this solution so don’t freak out and/or get too overwhelmed. Stay the course. 😉

curly-bug-out

I first explored Sitecore.Buckets.Gutters.BucketGutter in Sitecore.Buckets.dll to see if I should take note of anything special I need to know about when creating custom Sitecore.Shell.Applications.ContentEditor.Gutters.GutterRenderer — this lives in Sitecore.Kernel.dll and needs to be subclassed when adding to the Sitecore Gutter — subclasses for Item Buckets.

I noticed there is code in there which ascertains whether the Item Buckets feature is turned on/off, and obviously returns a null instance of Sitecore.Shell.Applications.ContentEditor.Gutters.GutterIconDescriptor — this lives in Sitecore.Kernel.dll — via its GetIconDescriptor() method.

I decided I needed to a way to also ascertain this. I came up with the following interface for classes that determined whether a feature is enabled or not:

namespace Sitecore.Sandbox.Determiners.Features
{
    public interface IFeatureDeterminer
    {
        bool IsEnabled();
    }
}

I then implemented the above interface with the following class:

using Sitecore.ContentSearch;
using Sitecore.ContentSearch.Utilities;

using Sitecore.Sandbox.Determiners.Features;

namespace Sitecore.Sandbox.Buckets.Determiners.Features
{
    public class ItemBucketsFeatureDeterminer : IFeatureDeterminer
    {
        public virtual bool IsEnabled()
        {
            return ContentSearchManager.Locator.GetInstance<IContentSearchConfigurationSettings>().ItemBucketsEnabled();
        }
    }
}

The code in the IsEnabled() method basically returns a boolean indicating whether the Item Buckets feature is turned on/off.

We now need classes whose instances can ascertain whether a bucketed Item’s path matches the paths generated by the bucketing algorithms they represent. I created the following class whose instances would serve as a parameters object to these objects:

using System;

using Sitecore.Data;
using Sitecore.Data.Items;

namespace Sitecore.Sandbox.Buckets.Ascertainers
{
    public class BucketFolderPathAscertainerParameters
    {
        public Item BucketItem { get; set; }

        public Item BucketedItem { get; set; }

        public DateTime CreationDateOfNewItem { get; set; }
    }
}

We’re just going to pass the Item Bucket, the bucketed Item and the creation date of the bucketed Item.

Next, we need those objects that ascertain whether a given Item Bucket uses a particular bucketing algorithm for its folder structure. I created the following interface for classes whose instances do just that:


namespace Sitecore.Sandbox.Buckets.Ascertainers
{
    public interface IBucketFolderPathAscertainer
    {
        string GetIcon();

        string GetToolTip();

        bool IsFolderPathMatch(BucketFolderPathAscertainerParameters parameters);
    }
}

After implementing two classes which implemented the interface above, I noticed some code similarities between them, and decided to employ Martin Fowler‘s refactoring technique Pull Up Method to move up these code similarities into a base class — I highly recommend reading his book Refactoring: Improving the Design of Existing Code which discusses this refactoring technique as well as a host of others — to make it easier for creating future subclasses and to hopefully abate the chances of code duplication. That exercise gave birth to the following abstract class:

using System;

using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;

namespace Sitecore.Sandbox.Buckets.Ascertainers
{
    public abstract class BucketFolderPathAscertainer : IBucketFolderPathAscertainer
    {
        private string icon;
        protected string Icon
        {
            get
            {
                Assert.IsNotNullOrEmpty(icon, "Icon must be set in configuration!");
                return icon;
            }
            set
            {
                Assert.IsNotNullOrEmpty(value, "Icon must be set in configuration!");
                icon = value;
            }
        }

        private string toolTip;
        protected string ToolTip
        {
            get
            {
                Assert.IsNotNullOrEmpty(toolTip, "ToolTip must be set in configuration!");
                return toolTip;
            }
            set
            {
                Assert.IsNotNullOrEmpty(value, "ToolTip must be set in configuration!");
                toolTip = value;
            }
        }
        
        public virtual string GetIcon()
        {
            return Icon;
        }

        public virtual string GetToolTip()
        {
            return ToolTip;
        }

        public bool IsFolderPathMatch(BucketFolderPathAscertainerParameters parameters)
        {
            EnsureParameters(parameters);
            ID settingsItemID = GetSettingsItemID();
            Assert.IsTrue(!ID.IsNullOrEmpty(settingsItemID), "GetSettingsItemID() cannot return null or empty!");
            Item settingsItem = parameters.BucketItem.Database.GetItem(settingsItemID);
            Assert.IsNotNull(settingsItem, string.Format("Setting Item does not exist! Make sure it exists! Item ID: {0}", settingsItemID.ToString()));

            string resolvedPath = GetResolvedPath(parameters, settingsItem);
            if (string.IsNullOrWhiteSpace(resolvedPath))
            {
                return false;
            }

            return IsPathMatch(parameters, resolvedPath);
        }

        protected virtual void EnsureParameters(BucketFolderPathAscertainerParameters parameters)
        {
            Assert.ArgumentNotNull(parameters, "parameters");
            Assert.ArgumentNotNull(parameters.BucketItem, "parameters.BucketItem");
            Assert.ArgumentNotNull(parameters.BucketedItem, "parameters.BucketedItem");
            Assert.ArgumentCondition(parameters.BucketItem.Database == parameters.BucketedItem.Database, "parameters.BucketItem.Database", "parameters.BucketItem.Database and parameters.BucketedItem.Database must be the same database");
            Assert.ArgumentCondition(parameters.BucketItem.Axes.IsAncestorOf(parameters.BucketedItem), "parameters.BucketItem", string.Format("parameters.BucketItem", "Bucket Item: {0} must be an ancestor of Bucketed Item: {1}", parameters.BucketItem.ID.ToString(), parameters.BucketedItem.ID.ToString()));
            Assert.ArgumentNotNull(parameters.BucketedItem, "parameters.BucketedItem");
        }

        protected virtual ID GetSettingsItemID()
        {
            return Sitecore.Buckets.Util.Constants.SettingsItemId;
        }

        protected abstract string GetResolvedPath(BucketFolderPathAscertainerParameters parameters, Item settingsItem);

        protected virtual bool IsPathMatch(BucketFolderPathAscertainerParameters parameters, string resolvedPath)
        {
            if (string.IsNullOrWhiteSpace(resolvedPath))
            {
                return false;
            }

            string bucketedFolderPath = parameters.BucketedItem.Paths.ParentPath.Replace(parameters.BucketItem.Paths.FullPath, string.Empty);
            if(bucketedFolderPath.StartsWith("/"))
            {
                bucketedFolderPath = bucketedFolderPath.Substring(1);
            }

            return string.Equals(bucketedFolderPath, resolvedPath, StringComparison.OrdinalIgnoreCase);
        }
    }
}

The Icon and ToolTip property values in the above class live in the patch configuration file further down in this post. The Sitecore Configuration Factory will inject those values into these properties, and the GetIcon() and GetToolTip() will return the values housed in the Icon and ToolTip properties, respectively.

The IsFolderPathMatch() gets the settingsItem Item instance — this Item lives in /sitecore/system/Settings/Buckets/Item Buckets Settings in Sitecore — which is needed by the GetResolvedPath() method — this method is declared abstract and must be implemented by subclasses — whose job it is to get the bucketed Item’s folder path via the algorithm which the subclass implementation represents.

When the algorithm path is return, it is then passed to the IsPathMatch() method which determines if there is a match. If there is a match, true is returned to the caller of the IsFolderPathMatch() method; false is returned otherwise.

The class above combined with its subclasses would be an example of the Template method design pattern in action.

Now, we need a subclass of the above to determine if a bucketed Item’s path was generated by the Sitecore Rules Engine. Since we don’t want the Rules Engine to evaluate the rules defined on /sitecore/system/Settings/Buckets/Item Buckets Settings for the bucketed Item given the Item Bucket’s current state — its “when” Condition will most likely evaluate to false — we need a way to trick the Rules Engine. I came up with the following Condition class that always evaluates to true:

using Sitecore.Rules;
using Sitecore.Rules.Conditions;

namespace Sitecore.Sandbox.Rules
{
    public class AlwaysTrueWhenCondition<TRuleContext> : WhenCondition<TRuleContext> where TRuleContext : RuleContext
    {
        protected override bool Execute(TRuleContext ruleContext)
        {
            return true;
        }
    }
}

There isn’t much going on in the above class. Its Execute() method always returns true.

The following subclass of the BucketFolderPathAscertainer class determines if a bucketed Item’s path was generated by the Sitecore Rules Engine:

using System;
using System.Collections.Generic;

using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Buckets.Rules.Bucketing;
using Sitecore.Rules;
using Sitecore.Sandbox.Rules;

namespace Sitecore.Sandbox.Buckets.Ascertainers
{
    public class RulesDefinedBucketFolderPathAscertainer : BucketFolderPathAscertainer
    {
        protected override string GetResolvedPath(BucketFolderPathAscertainerParameters parameters, Item settingsItem)
        {
            string bucketRulesFieldId = GetBucketRulesFieldId();
            Assert.IsNotNullOrEmpty(bucketRulesFieldId, "GetBucketRulesFieldId() cannot return null or empty!");
            BucketingRuleContext ruleContext = CreateNewBucketingRuleContext(parameters);
            RuleList<BucketingRuleContext> rules = GetRuleList<BucketingRuleContext>(settingsItem, bucketRulesFieldId);
            SetAlwaysTrueWhenConditions(rules.Rules);
            if (rules == null)
            {
                return string.Empty;
            }

            try
            {
                rules.Run(ruleContext);
            }
            catch (Exception ex)
            {
                Log.Error(ToString(), ex, this);
            }

            return ruleContext.ResolvedPath;
        }

        protected virtual string GetBucketRulesFieldId()
        {
            return Sitecore.Buckets.Util.Constants.BucketRulesFieldId;
        }

        protected virtual BucketingRuleContext CreateNewBucketingRuleContext(BucketFolderPathAscertainerParameters parameters)
        {
            return new BucketingRuleContext(parameters.BucketedItem.Database, parameters.BucketItem.ID, parameters.BucketedItem.ID, parameters.BucketedItem.Name,
                            parameters.BucketedItem.TemplateID, parameters.CreationDateOfNewItem)
            {
                NewItemId = parameters.BucketedItem.ID,
                CreationDate = parameters.CreationDateOfNewItem
            };
        }

        protected virtual RuleList<T> GetRuleList<T>(Item settingsItem, string bucketRulesFieldId) where T : BucketingRuleContext
        {
            Assert.ArgumentNotNull(settingsItem, "settingsItem");
            Assert.ArgumentNotNullOrEmpty(bucketRulesFieldId, "bucketRulesFieldId");
            return RuleFactory.GetRules<T>(new[] { settingsItem }, bucketRulesFieldId);
        }

        protected virtual void SetAlwaysTrueWhenConditions<TRuleContext>(IEnumerable<Rule<TRuleContext>> rules) where TRuleContext : RuleContext
        {
            foreach(Rule<TRuleContext> rule in rules)
            {
                rule.Condition = CreateNewAlwaysTrueWhenCondition<TRuleContext>();
            }
        }

        protected virtual AlwaysTrueWhenCondition<TRuleContext> CreateNewAlwaysTrueWhenCondition<TRuleContext>() where TRuleContext : RuleContext
        {
            return new AlwaysTrueWhenCondition<TRuleContext>();
    }
    }
}

I’m not going to go too much into all the methods of this class given that most of the magic happens in the GetResolvedPath() method. It basically replaces all Conditions in the rules Sitecore.Rules.RulesList instance with an instance of the Condition class above; calls the Rules Engine API to evaluate the rules defined on the settingsItem instance though with the Conditions always returning true; and returns the resolved path to the caller.

The following class which also subclasses the BucketFolderPathAscertainer class — if I only had a dollar for every instance of the word “class” or “subclass” in this post — basically wraps an Sitecore.Buckets.Util.IDynamicBucketFolderPath instance — ahem, I mean employs the Adapter design pattern — where its GetResolvedPath() method delegates a call to the IDynamicBucketFolderPath instances GetFolderPath() method:

using Sitecore.Buckets.Util;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;

namespace Sitecore.Sandbox.Buckets.Ascertainers
{
    public class DynamicBucketFolderPathPathAscertainer : BucketFolderPathAscertainer
    {
        private IDynamicBucketFolderPath PathResolver { get; set; }

        protected override string GetResolvedPath(BucketFolderPathAscertainerParameters parameters, Item settingsItem)
        {
            Assert.IsNotNull(PathResolver, "The IDynamicBucketFolderPath instance named PathResolver must be set in configuration!");
            return PathResolver.GetFolderPath(parameters.BucketItem.Database, parameters.BucketedItem.Name, parameters.BucketedItem.TemplateID,
                                                                parameters.BucketedItem.ID, parameters.BucketItem.ID, parameters.CreationDateOfNewItem);
        }
    }
}

You might be asking “what are we using the above class for?” Well, we are going to inject an instance of Sitecore.Buckets.Util.DateBasedFolderPath into the PathResolver property via the Sitecore Configuration Factory (please see the patch configuration file further down in this post).

I thought it might be cumbersome for a class to make calls to every single IBucketFolderPathAscertainer instance — sure, we only have two above but just think about how messy things will quickly progress if more are added. I decided I would utilize the Composite design pattern via the following class:

using System.Collections.Generic;
using System.Linq;
using System.Xml;

using Sitecore.Configuration;
using Sitecore.Diagnostics;

namespace Sitecore.Sandbox.Buckets.Ascertainers
{
    
    public class CompositeBucketFolderPathAscertainer : IBucketFolderPathAscertainer
    {
        private string Icon { get; set; }

        private string ToolTip { get; set; }

        private List<IBucketFolderPathAscertainer> FolderPathAscertainers { get; set; }

        public CompositeBucketFolderPathAscertainer()
        {
            FolderPathAscertainers = new List<IBucketFolderPathAscertainer>();
        }

        public string GetIcon()
        {
            return Icon;
        }

        protected virtual void SetIcon(string icon)
        {
            Assert.ArgumentNotNullOrEmpty(icon, "icon");
            Icon = icon;
        }

        public string GetToolTip()
        {
            return ToolTip;
        }

        protected virtual void SetToolTip(string toolTip)
        {
            Assert.ArgumentNotNullOrEmpty(toolTip, "toolTip");
            ToolTip = toolTip;
        }

        public bool IsFolderPathMatch(BucketFolderPathAscertainerParameters parameters)
        {
            if(FolderPathAscertainers == null || !FolderPathAscertainers.Any())
            {
                return false;
            }

            foreach(IBucketFolderPathAscertainer ascertainer in FolderPathAscertainers)
            {
                if(ascertainer.IsFolderPathMatch(parameters))
                {
                    SetIcon(ascertainer.GetIcon());
                    SetToolTip(ascertainer.GetToolTip());
                    return true;
                }
            }

            return false;
        }

        protected virtual void AddFolderPathAscertainer(XmlNode configNode)
        {
            if(configNode == null)
            {
                return;
            }

            IBucketFolderPathAscertainer ascertainer = Factory.CreateObject(configNode, false) as IBucketFolderPathAscertainer;
            Assert.IsNotNull(ascertainer, "An IBucketFolderPathAscertainer was not defined correctly in configuration!");
            FolderPathAscertainers.Add(ascertainer);
        }
    }
}

All configuration-defined IBucketFolderPathAscertainer instances will be added to the FolderPathAscertainers List property via the AddFolderPathAscertainer() method (have a look at the configuration file below to see the AddFolderPathAscertainer() method being there for the Sitecore Configuration Factory to use).

The IsFolderPathMatch() method will then iterate over all IBucketFolderPathAscertainer instances and try to find a match. If a match is found, the instance of the class above will grab and save local copies of that IBucketFolderPathAscertainer instance’s Icon and Tooltip values, and then return true. If no match was found, it returns false.

Now, we need a way to grab one bucketed Item for an Item Bucket. I defined the following interface for a class whose instance will return one Item given another Item:

using Sitecore.Data.Items;

namespace Sitecore.Sandbox.Providers.Items
{
    public interface IItemProvider
    {
        Item GetItem(Item item);
    }
}

The following class implements the interface above:

using System;
using System.Linq;
using System.Linq.Expressions;

using Sitecore.ContentSearch;
using Sitecore.ContentSearch.Linq;
using Sitecore.ContentSearch.Linq.Utilities;
using Sitecore.ContentSearch.SearchTypes;
using Sitecore.ContentSearch.Utilities;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;

using Sitecore.Sandbox.Providers.Items;

namespace Sitecore.Sandbox.Buckets.Providers.Items
{
    public class BucketedItemProvider : IItemProvider
    {
        private string BucketFolderTemplateId { get; set; }

        private string SearchIndexName { get; set; }

        private ISearchIndex searchIndex;
        private ISearchIndex SearchIndex
        {
            get
            {
                if (searchIndex == null && !string.IsNullOrWhiteSpace(SearchIndexName))
                {
                    searchIndex = GetSearchIndex(SearchIndexName);
                }

                return searchIndex;
            }
        }

        public virtual Item GetItem(Item bucketItem)
        {
            ID bucketFolderTemplateId;
            Assert.ArgumentCondition(ID.TryParse(BucketFolderTemplateId, out bucketFolderTemplateId), "BucketFolderTemplateId", "BucketFolderTemplateId cannot be empty and must be a Sitecore.Data.ID! Check its configuration setting!");
            Assert.ArgumentNotNull(bucketItem, "bucketItem");
            Assert.IsNotNullOrEmpty(SearchIndexName, "SearchIndexName is empty. Double-check its configuration setting!");
            Assert.IsNotNull(SearchIndex, "SearchIndex is null. Double-check the SearchIndexName configuration setting!");

            using (IProviderSearchContext searchContext = SearchIndex.CreateSearchContext())
            {
                var predicate = GetSearchPredicate<SearchResultItem>(bucketItem.ID, bucketFolderTemplateId);
                IQueryable<SearchResultItem> query = searchContext.GetQueryable<SearchResultItem>().Filter(predicate);
                SearchResults<SearchResultItem> results = query.GetResults();
                if (results.Count() < 1)
                {
                    return null;
                }

                SearchHit<SearchResultItem> hit = results.Hits.First();
                return hit.Document.GetItem();
            }
        }

        protected virtual ISearchIndex GetSearchIndex(string searchIndexName)
        {
            Assert.ArgumentNotNullOrEmpty(searchIndexName, "searchIndexName");
            return ContentSearchManager.GetIndex(searchIndexName);
        }

        protected virtual Expression<Func<T, bool>> GetSearchPredicate<T>(ID bucketItemId, ID bucketFolderTemplateId) where T : SearchResultItem
        {
            var predicate = PredicateBuilder.True<T>();
            predicate = predicate.And(item => item.Paths.Contains(bucketItemId));
            predicate = predicate.And(item => item.TemplateId != bucketFolderTemplateId);
            predicate = predicate.And(item => item.Parent != bucketItemId);
            predicate = predicate.And(item => item.ItemId != bucketItemId);
            return predicate;
        }
    }
}

The class above is leveraging the Sitecore.ContentSearch API to get this bucketed Item.

Why am I using the Sitecore.ContentSearch API? Well, imagine if there are thousands if not tens of thousands of bucketed Items under the Item Bucket. A Sitecore query would be slow as molasses, and we need keep performance on our minds at all times for all of our solutions. Don’t lose sight of that on anything you build.

The GetSearchPredicate() method builds up and returns an Expression<Func> instance — let’s call this instance the “predicate”. The predicate basically says we want an Item who is a descendant of the Item Bucket; isn’t a Bucket Folder Item; lives under a Bucket Folder; and isn’t the Item Bucket Item.

The GetItem() method then uses that predicate and the Sitecore.ContentSearch API to gather those SearchResultItem instances, and then returns the Item instance on the first one in the result set if any were returned. If none were found, it returns null.

Since we have a lot of moving parts in this solution — just look at all of the classes you’ve just gone through — I need a way to piece all of this together for a GutterRenderer.

Unfortunately, we can’t magically inject instances into a GutterRenderer via the Sitecore Configuration Factory — well, you can call it but imagine all the calls I would need for this — so I decided to define the following interface whose classes would be used by a GutterRenderer, and these classes would be defined in Sitecore configuration:

using Sitecore.Data.Items;
using Sitecore.Shell.Applications.ContentEditor.Gutters;

namespace Sitecore.Sandbox.Shell.Applications.ContentEditor.Gutters
{
    public interface IGutter
    {
        GutterIconDescriptor GetIconDescriptor(Item item);

        bool IsVisible();
    }
}

The following class implements the interface above:

using System;

using Sitecore.Buckets.Extensions;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Globalization;
using Sitecore.Shell.Applications.ContentEditor.Gutters;

using Sitecore.Sandbox.Buckets.Ascertainers;
using Sitecore.Sandbox.Determiners.Features;
using Sitecore.Sandbox.Providers.Items;
using Sitecore.Sandbox.Shell.Applications.ContentEditor.Gutters;

namespace Sitecore.Sandbox.Buckets.Gutters
{
    public class FolderPathBucketGutter : IGutter
    {
        private string DefaultIcon { get; set; }

        private string DefaultToolTip { get; set; }

        private string CreatedDatetimeFieldName { get; set; }

        private IFeatureDeterminer ItemBucketsFeatureDeterminer { get; set; }
        
        private IItemProvider BucketedItemProvider { get; set; }

        private IBucketFolderPathAscertainer FolderPathAscertainer { get; set; }

        public virtual GutterIconDescriptor GetIconDescriptor(Item item)
        {
            EnsureRequiredProperties();
            Assert.ArgumentNotNull(item, "item");

            if(!AreItemBucketsEnabled() || !item.IsABucket())
            {
                return null;
            }

            Item bucketedItem = GetBucketedItem(item);
            if(bucketedItem == null)
            {
                return CreateNewGutterIconDescriptor(DefaultIcon, DefaultToolTip);
            }

            BucketFolderPathAscertainerParameters parameters = new BucketFolderPathAscertainerParameters
            {
                BucketItem = item,
                BucketedItem = bucketedItem,
                CreationDateOfNewItem = GetItemCreatedDateTime(bucketedItem)
            };

            if(!FolderPathAscertainer.IsFolderPathMatch(parameters))
            {
                return CreateNewGutterIconDescriptor(DefaultIcon, DefaultToolTip);
            }

            return CreateNewGutterIconDescriptor(FolderPathAscertainer.GetIcon(), FolderPathAscertainer.GetToolTip());
        }

        protected virtual void EnsureRequiredProperties()
        {
            Assert.IsNotNull(FolderPathAscertainer, "FolderPathAscertainer must be defined in configuration!");
            Assert.IsNotNullOrEmpty(DefaultIcon, "DefaultIcon must be defined in configuration!");
            Assert.IsNotNullOrEmpty(DefaultToolTip, "DefaultToolTip must be defined in configuration!");
            Assert.IsNotNullOrEmpty(CreatedDatetimeFieldName, "CreatedDatetimeFieldName must be defined in configuration!");
        }

        public virtual bool IsVisible()
        {
            return AreItemBucketsEnabled();
        }

        protected virtual bool AreItemBucketsEnabled()
        {
            Assert.IsNotNull(ItemBucketsFeatureDeterminer, "ItemBucketsFeatureDeterminer must be set in configuration!");
            return ItemBucketsFeatureDeterminer.IsEnabled();
        }

        protected virtual Item GetBucketedItem(Item bucketItem)
        {
            Assert.IsNotNull(BucketedItemProvider, "BucketedItemProvider must be set in configuration!");
            return BucketedItemProvider.GetItem(bucketItem);
        }

        protected virtual GutterIconDescriptor CreateNewGutterIconDescriptor(string icon, string toolTip)
        {
            Assert.ArgumentNotNullOrEmpty(icon, "icon");
            Assert.ArgumentNotNullOrEmpty(toolTip, "toolTip");
            return new GutterIconDescriptor
            {
                Icon = icon,
                Tooltip = TranslateText(toolTip)
            };
        }

        protected virtual string TranslateText(string text)
        {
            Assert.ArgumentNotNullOrEmpty(text, "text");
            return Translate.Text(text);
        }

        protected virtual DateTime GetItemCreatedDateTime(Item item)
        {
            Assert.IsNotNullOrEmpty(CreatedDatetimeFieldName, "CreatedDatetimeFieldName must be defined in configuration!");
            Assert.ArgumentNotNull(item, "item");
            DateField created = item.Fields[CreatedDatetimeFieldName];
            if(created == null)
            {
                return DateTime.MinValue;
            }

            return created.DateTime;
        }
    }
}

The AreItemBucketsEnabled() method in the above classes determines if the Item Bucket feature is enabled via the injected IBucketFolderPathAscertainer instance. This method is then used by the IsVisible() method which represents the method by the same name on GutterRenderer instances, and is also called by the GetIconDescriptor() method.

If the Item Buckets feature is not enabled, the GetIconDescriptor() method will return null as well as when the passed Item is not an Item Bucket.

If the passed Item is an Item Bucket, the GetIconDescriptor() gets one bucketed Item via the GetBucketedItem() method — this method just delegates to the IItemProvider instance injected into the class instance — and puts this bucketed Item as well as the Item Bucket into a BucketFolderPathAscertainerParameters parameters object instance. The creation date of the bucketed is also set on this parameters object since it is required when ascertaining whether the folder structure was constructed based on its creation date.

The BucketFolderPathAscertainerParameters instance is then passed to the IsFolderPathMatch() method on the injected IBucketFolderPathAscertainer property which determines if there is a folder path match.

If there is a match, a new GutterIconDescriptor instance is returned which contains the appropriate Icon and Tooltip.

If there is no match, then a new GutterIconDescriptor is returned with default values for the Icon and Tooltip.

This next class subclasses the GutterRenderer class:

using System;

using Sitecore.Configuration;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Shell.Applications.ContentEditor.Gutters;

namespace Sitecore.Sandbox.Shell.Applications.ContentEditor.Gutters
{
    public class ConfigDefinedGutterRenderer : GutterRenderer
    {
        private IGutter gutter;
        private IGutter Gutter
        {
            get
            {
                if (gutter == null)
                {
                    gutter = GetInnerGutterRenderer();
                }

                return gutter;
            }
        }

        protected override GutterIconDescriptor GetIconDescriptor(Item item)
        {
            Assert.IsNotNull(Gutter, "Gutter wasn't set properly. Double-check it!");
            return Gutter.GetIconDescriptor(item);
        }

        public override bool IsVisible()
        {
            Assert.IsNotNull(Gutter, "Gutter wasn't set properly. Double-check it!");
            return Gutter.IsVisible();
        }

        protected virtual IGutter GetInnerGutterRenderer()
        {
            string configPath = GetConfigPath();
            if (string.IsNullOrWhiteSpace(configPath))
            {
                Log.Error("ConfigDefinedGutterRenderer: configPath must be set as a parameter!", this);
                return null;
            }

            try
            {
                IGutter gutter = Factory.CreateObject(configPath, false) as IGutter;
                if (gutter == null)
                {
                    Log.Error(string.Format("ConfigDefinedGutterRenderer: the IGutter defined in {0} isn't correctly defined. Double-check it!", configPath), this);
                    return null;
                }

                return gutter;

            }
            catch (Exception ex)
            {
                Log.Error(ToString(), ex, this);
            }

            return null;
        }

        protected virtual string GetConfigPath()
        {
            string key = "configPath";
            if (Parameters.ContainsKey(key))
            {
                return Parameters[key];
            }

            return string.Empty;
        }
    }
}

The GetInnerGutterRenderer() method above calls the Sitecore Configuration Factory to grab an IGutter instance from a configuration path which is set on the Parameters field of the definition Item for this GutterRenderer in the Core database — see the screenshot further down in this post — when the Gutter property is called for the first time, and sets this instance on a private member on the class instance.

Both the GetIconDescriptor() and IsVisible() methods delegate to the methods on the IGutter instance with the same names (quiz time: what design pattern is this class using? 😉 ).

I then duct-taped everything together via the following patch configuration file:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <gutters>
      <folderPathBucketGutter type="Sitecore.Sandbox.Buckets.Gutters.FolderPathBucketGutter, Sitecore.Sandbox" singleInstance="true">
        <DefaultIcon>business/32x32/chest_add.png</DefaultIcon>
        <DefaultToolTip>This item is a bucket. You can use this as a content repository.</DefaultToolTip>
        <CreatedDatetimeFieldName>__Created</CreatedDatetimeFieldName>
        <ItemBucketsFeatureDeterminer ref="determiners/features/itemBucketsFeatureDeterminer" />
        <BucketedItemProvider ref="providers/items/bucketedItemProvider" />
        <FolderPathAscertainer ref="ascertainers/buckets/compositeBucketFolderPathAscertainer" />
      </folderPathBucketGutter>
    </gutters>
    <ascertainers>
      <buckets>
        <compositeBucketFolderPathAscertainer type="Sitecore.Sandbox.Buckets.Ascertainers.CompositeBucketFolderPathAscertainer, Sitecore.Sandbox" singleInstance="true">
          <ascertainers hint="raw:AddFolderPathAscertainer">
            <ascertainer ref="ascertainers/buckets/rulesDefinedBucketFolderPathAscertainer" />
            <ascertainer ref="ascertainers/buckets/dateBasedFolderPathAscertainer" />
          </ascertainers>
        </compositeBucketFolderPathAscertainer>
        <dateBasedFolderPathAscertainer type="Sitecore.Sandbox.Buckets.Ascertainers.DynamicBucketFolderPathPathAscertainer, Sitecore.Sandbox" singleInstance="true">
          <Icon>Business/32x32/calendar_down.png</Icon>
          <ToolTip>This item is a bucket. Its bucket folders were generated based on the creation date of the bucketed items. You can use this as a content repository.</ToolTip>
          <PathResolver type="Sitecore.Buckets.Util.DateBasedFolderPath, Sitecore.Buckets" />
        </dateBasedFolderPathAscertainer>
        <rulesDefinedBucketFolderPathAscertainer type="Sitecore.Sandbox.Buckets.Ascertainers.RulesDefinedBucketFolderPathAscertainer, Sitecore.Sandbox"
        singleInstance="true">
          <Icon>Business/32x32/briefcase_add.png</Icon>
          <ToolTip>This item is a bucket. Its bucket folders were generated by the rules engine. You can use this as a content repository.</ToolTip>
        </rulesDefinedBucketFolderPathAscertainer>
      </buckets>
    </ascertainers>
    <determiners>
      <features>
        <itemBucketsFeatureDeterminer type="Sitecore.Sandbox.Buckets.Determiners.Features.ItemBucketsFeatureDeterminer" singleInstance="true" />
      </features>
    </determiners>
    <providers>
      <items>
        <bucketedItemProvider type="Sitecore.Sandbox.Buckets.Providers.Items.BucketedItemProvider" singleInstance="true">
          <BucketFolderTemplateId>{ADB6CA4F-03EF-4F47-B9AC-9CE2BA53FF97}</BucketFolderTemplateId>
          <SearchIndexName>sitecore_master_index</SearchIndexName>
        </bucketedItemProvider>
      </items>
    </providers>
  </sitecore>
</configuration>

We need to let Sitecore know about the new Gutter addition. I did this in the Core database:

smart-bucket-gutter-core-db

One thing to keep in mind is that the Sitecore Rules Engine folder path match will only work when we have an algorithm that will return the same path consistently for a bucketed Item. This unfortunately means I could not use the same Action from my previous post given that it generates random folder paths.

To over come this hurdle, I built the following Action which just reverses the bucketed Item’s ID (oh no, more code 😉 ):

using System.Collections.Generic;
using System.Linq;

using Sitecore.Buckets.Rules.Bucketing;
using Sitecore.Buckets.Rules.Bucketing.Actions;
using Sitecore.Diagnostics;

namespace Sitecore.Sandbox.Buckets.Rules.Actions
{
    public class CreateReversedIDBasedPath<TContext> : CreateIDBasedPath<TContext> where TContext : BucketingRuleContext
    {
        public override void Apply(TContext ruleContext)
        {
            Assert.ArgumentNotNull(ruleContext, "ruleContext");
            base.Apply(ruleContext);
            if (string.IsNullOrWhiteSpace(ruleContext.ResolvedPath))
            {
                return;
            }

            ruleContext.ResolvedPath = ReversePath(ruleContext.ResolvedPath);
        }

        protected virtual string ReversePath(string path)
        {
            if(string.IsNullOrWhiteSpace(path))
            {
                return string.Empty;
            }

            List<string> pieces = path.Split('/').ToList();
            pieces.Reverse();
            return string.Join("/", pieces);
        }
    }
}

I’m not going to go into details of how I set this in the rules on /sitecore/system/Settings/Buckets/Item Buckets Settings — you can see an example of how this is done from my previous post.

Now that everything is set, we can see that the new Gutter option is available:

smart-bucket-gutter-right-click-lets-turn-on

I then turned it on:

smart-gutter-new-gutter-turned-on

As you can see, we have different Gutter icons for different folder structures.

If you have any thoughts on this, please share in a comment.

Oh, by the way, if you made it all the way to the end of this post, then you deserve a treat. Go get yourself a cookie. You deserve it. 😉

cookie

Until next time, keep up the good fight, one piece of code at time. 😀

Customize How Item Bucket Folder Paths are Created Using the Sitecore Rules Engine

I bet you all love Sitecore Item Buckets, right? Everyone loves Item Buckets, even this cat:

Cat-Traps-Itself-Inside-Bucket

If you don’t know what Item Buckets are — and if you don’t I recommend having a read of this pdf — they are Items that contain a nested structure of Bucket Folder Items, and the leaf Bucket Folder Items contain bucketable Items.

What’s the purpose of this? Well, Item Buckets offer a way to store a massive amount of Items under the nested structure of Bucket Folders Items. This offers better performance over having a large amount of Items under one parent Item:

item-bucket

By default, the Bucket Folder Items are named based on a DateTime format which is housed in a configuration setting in Sitecore:

bucket-folder-date-time-format

One thing I’ve always been curious about was what code used the above setting to create path for these Bucket Folders — I wanted to see if I could override it, and add my own logic to name the Bucket Folder Items using some different algorithm.

I found that this code lives in Sitecore.Buckets.Util.BucketFolderPathResolver in Sitecore.Buckets.dll.

However, that wasn’t the only thing I saw in this class — I also saw code in there leveraging the Sitecore Rules Engine as well. Basically the Rules Engine code would execute first, and if it returned no path structure — as a string — the default DateTime format code would execute.

After talking with Sitecore MVP Robbert Hock about it on the Sitecore Community Slack, he shared the following post by Alex van Wolferen which highlights that you can create custom conditions and actions for the Rules Engine to generate a custom folder path for the Bucket Folders, and even showed how you can set this stuff up in the Content Editor. Really? How did I not know about this? I must have been asleep when the Item Buckets feature was released. 😉

The only unfortunate thing about that blog post is there is no code to look at. 😦

As an alternative method of discovery, I surfed through Sitecore.Buckets.dll to see how the existing Bucket conditions and actions were built, and then decided have a bit of fun: I created two custom conditions and one custom action.

The following class serves as my first custom condition which determines if the Item Bucket has any presentation:

using Sitecore.Buckets.Rules.Bucketing;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Pipelines.HasPresentation;
using Sitecore.Rules.Conditions;

namespace Sitecore.Sandbox.Buckets.Rules.Conditions
{
    public class WhenBucketHasNoPresentation<TRuleContext> : WhenCondition<TRuleContext> where TRuleContext : BucketingRuleContext
    {
        protected override bool Execute(TRuleContext ruleContext)
        {
            Assert.ArgumentNotNull(ruleContext, "ruleContext");
            Assert.ArgumentNotNull(ruleContext.BucketItem, "ruleContext.BucketItem");
            return !HasPresentation(ruleContext.BucketItem);
        }

        protected virtual bool HasPresentation(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            return HasPresentationPipeline.Run(item);
        }
    }
}

The Execute() method in the above class gets an instance of the Item Bucket from the BucketingRuleContext instance, and passes it off to the HasPresentation() method which determines whether the Item Bucket has any presentation components defined on it.

In order for Sitecore to know about the class above, we need to create a new Condition Item. I did just that here:

bucket-folder-path-condition-no-presentation

The following class serves as another condition though this condition determines if the Item Bucket has N or more child Items underneath it (N is an integer set on the rule which I show further down in this post):

using Sitecore.Buckets.Rules.Bucketing;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Rules.Conditions;

namespace Sitecore.Sandbox.Buckets.Rules.Conditions
{
    public class WhenBucketHasNChildrenOrMore<TRuleContext> : WhenCondition<TRuleContext> where TRuleContext : BucketingRuleContext
    {
        public string Value { get; set; }

        protected override bool Execute(TRuleContext ruleContext)
        {
            Assert.ArgumentNotNull(ruleContext, "ruleContext");
            Assert.ArgumentNotNull(ruleContext.BucketItem, "ruleContext.BucketItem");
            return ShouldExecute(ruleContext.BucketItem);
        }

        protected virtual bool ShouldExecute(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            int excessiveNumber = GetExcessiveNumberOfChildren();
            return excessiveNumber > 0 && item.Children.Count >= excessiveNumber;
        }

        protected virtual int GetExcessiveNumberOfChildren()
        {
            int excessiveNumber;
            if(!int.TryParse(Value, out excessiveNumber))
            {
                return 0;
            }

            return excessiveNumber;
        }
    }
}

The GetExcessiveNumberOfChildren() method tries to parse an integer from the value set on the Value property on the class instance. If the value is not set, it just returns zero.

The above method is called by the ShouldExecute() method which ascertains whether the value is greater zero, and if it is, whether the Item Bucket has that amount of child Items or more. If both of these are true, the method returns true; false otherwise.

The boolean value of the ShouldExecute() method is then returned to the caller via the Execute() method.

Just like the last condition class, we have to let Sitecore know about it. I did that by creating another Condition Item:

bucket-folder-path-condition-number-of-children

Now we need a class that builds up the Bucket Folder path. The following class serves as an action to do just that:

using System;
using System.Collections.Generic;

using Sitecore.Buckets.Rules.Bucketing;
using Sitecore.Diagnostics;
using Sitecore.Rules.Actions;

namespace Sitecore.Sandbox.Buckets.Rules.Actions
{
    public class CreateRandomIntegerBasedPath<TContext> : RuleAction<TContext> where TContext : BucketingRuleContext
    {
        private const int LengthOfInteger = 3;

        protected static readonly Random Random = new Random();

        public string Levels { get; set; }

        public override void Apply(TContext ruleContext)
        {
            Assert.ArgumentNotNull(ruleContext, "ruleContext");
            int levels = GetLevels();
            if(levels < 1)
            {
                Log.Error(string.Format("Cannot apply CreateRandomIntegerBasedPath action. The value of levels: {0}", Levels), this);
                return;
            }

            IEnumerable<string> integers = GetRandomIntegers(LengthOfInteger, levels);
            ruleContext.ResolvedPath = string.Join(Sitecore.Buckets.Util.Constants.ContentPathSeperator, integers);
        }

        protected virtual int GetLevels()
        {
            int levels;
            if(!int.TryParse(Levels, out levels) || levels < 1)
            {
                return 0;
            }

            return levels;
        }
        
        protected virtual IEnumerable<string> GetRandomIntegers(int lengthOfInteger, int count)
        {
            IList<string> strings = new List<string>();
            for (int i = 0; i < count; i++)
            {
                int number = GetRandomPowerOfTenNumber(lengthOfInteger);
                strings.Add(number.ToString());
            }

            return strings;
        }

        private int GetRandomPowerOfTenNumber(int exponent)
        {
            return Random.Next((int)Math.Pow(10, exponent - 1), (int)Math.Pow(10, exponent) - 1);
        }
    }
}

The class above, in a nutshell, reads in the value that governs how deep the folder structure should be (i.e. this is stored in the Levels string property which is set on the rule further down in this post); tries to parse this as an integer (we can’t do much if it’s not an integer); creates N strings composed of random numbers between 100 and 999 with endpoints inclusive (N is the integer value of Levels); builds up a string delimited by “/” to create a folder path; and returns it to the caller of the Apply() method.

Just like the condition classes, we need to register the above action class in Sitecore. I did this by creating a custom Action Item:

bucket-folder-path-action-create-random-folder-path

Now that we have our custom conditions and action ready to go, we need to piece them together into a rule. I did that in the ‘Rules for Resolving the Bucket Folder Path’ field on /sitecore/system/Settings/Buckets/Item Buckets Settings:

bucket-folder-path-item-buckets-settings

Let’s take this for a spin!

I created some test Items with some child items. The following Item has no presentation and 5 child items:

bucket-folder-path-5-items-no-presentation

Let’s turn it into an Item Bucket:

bucket-folder-path-5-items-no-presentation-click-bucket

As you can see, it has Bucket Folders with random integers as their Item names:

bucket-folder-path-5-items-no-presentation-bucket

Let’s now try this on an Item that has no presentation but only 4 child items:

bucket-folder-path-4-items-no-presentation

Let’s convert this Item into an Item Bucket:

bucket-folder-path-4-items-no-presentation-click-bucket

As you can see, the default Item Bucket folder structure was created:

bucket-folder-path-4-items-no-presentation-bucket

If you have any thoughts on this, please share in a comment.

Abstract Out Sitecore FileWatcher Logic Which Monitors Rendering Files on the File System

While digging through code of Sitecore.IO.FileWatcher subclasses this weekend, I noticed a lot of code similarities between the LayoutWatcher and XslWatcher classes, and thought it might be a good idea to abstract this logic out into a new base abstract class so that future FileWatchers which monitor other renderings on the file system can easily be added without having to write much logic.

Before I move forward on how I did this, let me explain what the LayoutWatcher FileWatcher does. The LayoutWatcher FileWatcher clears the html cache of all websites defined in Sitecore when it determines that a layout file (a.k.a .aspx) or sublayout file (a.k.a .ascx) has been changed, deleted, renamed or added.

Likewise, the XslWatcher FileWatcher does the same thing for XSLT renderings but also clears the XSL cache along with the html cache.

Ok, now back to the abstraction. I came up with the following class to serve as the base class for any FileWatcher that will monitor renderings that live on the file system:

using System;
using System.Collections.Generic;
using System.Linq;

using Sitecore.Configuration;
using Sitecore.Diagnostics;
using Sitecore.IO;
using Sitecore.Web;

namespace Sitecore.Sandbox.IO.Watchers
{
    public abstract class RenderingFileWatcher : FileWatcher
    {
        private string RenderingFileModifiedMessage { get; set; }

        private string RenderingFileDeletedMessage { get; set; }

        private string RenderingFileRenamedMessage { get; set; }

        private string FileWatcherErrorMessage { get; set; }

        private object Owner { get; set; }

        public RenderingFileWatcher(string configPath)
            : base(configPath)
        {
            SetMessages();
        }

        private void SetMessages()
        {
            string modifiedMessage = GetRenderingFileModifiedMessage();
            AssertNotNullOrWhiteSpace(modifiedMessage, "modifiedMessage", "GetRenderingFileModifiedMessage() cannot return null, empty or whitespace!");
            RenderingFileModifiedMessage = modifiedMessage;

            string deletedMessage = GetRenderingFileDeletedMessage();
            AssertNotNullOrWhiteSpace(deletedMessage, "deletedMessage", "GetRenderingFileDeletedMessage() cannot return null, empty or whitespace!");
            RenderingFileDeletedMessage = deletedMessage;

            string renamedMessage = GetRenderingFileRenamedMessage();
            AssertNotNullOrWhiteSpace(renamedMessage, "renamedMessage", "GetRenderingFileRenamedMessage() cannot return null, empty or whitespace!");
            RenderingFileRenamedMessage = renamedMessage;

            string errorMessage = GetFileWatcherErrorMessage();
            AssertNotNullOrWhiteSpace(errorMessage, "errorMessage", "GetFileWatcherErrorMessage() cannot return null, empty or whitespace!");
            FileWatcherErrorMessage = errorMessage;

            object owner = GetOwner();
            Assert.IsNotNull(owner, "GetOwner() cannot return null!");
            Owner = owner;
        }

        protected abstract string GetRenderingFileModifiedMessage();

        protected abstract string GetRenderingFileDeletedMessage();

        protected abstract string GetRenderingFileRenamedMessage();

        protected abstract string GetFileWatcherErrorMessage();

        protected abstract object GetOwner();

        private void AssertNotNullOrWhiteSpace(string argument, string argumentName, string errorMessage)
        {
            Assert.ArgumentCondition(!string.IsNullOrWhiteSpace(argument), argumentName, errorMessage);
        }

        protected override void Created(string fullPath)
        {
            try
            {
                Log.Info(string.Format("{0}: {1}", RenderingFileModifiedMessage, fullPath), Owner);
                ClearCaches();
            }
            catch (Exception ex)
            {
                Log.Error(FileWatcherErrorMessage, ex, Owner);
            }
        }

        protected override void Deleted(string filePath)
        {
            try
            {
                Log.Info(string.Format("{0}: {1}", RenderingFileDeletedMessage, filePath), Owner);
                ClearCaches();
            }
            catch (Exception ex)
            {
                Log.Error(FileWatcherErrorMessage, ex, Owner);
            }
        }

        protected override void Renamed(string filePath, string oldFilePath)
        {
            try
            {
                Log.Info(string.Format("{0}: {1}. Old path: {2}", RenderingFileRenamedMessage, filePath, oldFilePath), Owner);
                ClearCaches();
            }
            catch (Exception ex)
            {
                Log.Error(FileWatcherErrorMessage, ex, this);
            }
        }

        protected virtual void ClearCaches()
        {
            ClearHtmlCaches();
        }

        protected virtual void ClearHtmlCaches()
        {
            IEnumerable<SiteInfo> siteInfos = GetSiteInfos();
            if (IsNullOrEmpty(siteInfos))
            {
                return;
            }

            foreach(SiteInfo siteInfo in siteInfos)
            {
                if (siteInfo.HtmlCache != null)
                {
                    Log.Info(string.Format("Clearing Html Cache for site: {0}", siteInfo.Name), Owner);
                    siteInfo.HtmlCache.Clear();
                }
            }
        }

        protected virtual IEnumerable<SiteInfo> GetSiteInfos()
        {
            IEnumerable<string> siteNames = GetSiteNames();
            if (IsNullOrEmpty(siteNames))
            {
                return Enumerable.Empty<SiteInfo>();
            }

            IList<SiteInfo> siteInfos = new List<SiteInfo>();
            foreach(string siteName in siteNames)
            {
                SiteInfo siteInfo = Factory.GetSiteInfo(siteName);
                if(siteInfo != null)
                {
                    siteInfos.Add(siteInfo);
                }
            }

            return siteInfos;
        }

        protected virtual IEnumerable<string> GetSiteNames()
        {
            IEnumerable<string> siteNames = Factory.GetSiteNames();
            if(IsNullOrEmpty(siteNames))
            {
                return Enumerable.Empty<string>();
            }

            return siteNames;
        }

        protected virtual bool IsNullOrEmpty<T>(IEnumerable<T> collection)
        {
            if (collection == null || !collection.Any())
            {
                return true;
            }

            return false;
        }
    }
}

The above class defines five abstract methods which subclasses must implement. Data returned by these methods are used when logging information or errors in the Sitecore log. The SetMessages() method vets whether subclass returned these objects correctly, and sets them in private properties which are used in the Created(), Deleted() and Renamed() methods.

The Created(), Deleted() and Renamed() methods aren’t really doing anything different from each other — they are all clearing the html cache for each Sitecore.Web.SiteInfo instance returned by the GetSiteInfos() method, though I do want to point out that each of these methods are defined on the Sitecore.IO.FileWatcher base class and serve as event handlers for file system file actions:

  • Created() is invoked when a new file is dropped in a directory or subdirectory being monitored, or when a targeted file is changed.
  • Deleted() is invoked when a targeted file is deleted.
  • Renamed() is invoked when a targeted file is renamed.

In order to ascertain whether the code above works, I need a subclass whose instance will serve as the actual FileWatcher. I decided to build the following subclass which will monitor Razor files under the /Views directory of my Sitecore website root:

namespace Sitecore.Sandbox.IO.Watchers
{
    public class RazorViewFileWatcher : RenderingFileWatcher
    {
        public RazorViewFileWatcher()
            : base("watchers/view")
        {
        }
        
        protected override string GetRenderingFileModifiedMessage()
        {
            return "Razor View modified";
        }

        protected override string GetRenderingFileDeletedMessage()
        {
            return "Razor View deleted";
        }

        protected override string GetRenderingFileRenamedMessage()
        {
            return "Razor View renamed";
        }

        protected override string GetFileWatcherErrorMessage()
        {
            return "Error in RazorViewFileWatcher";
        }

        protected override object GetOwner()
        {
            return this;
        }
    }
}

I’m sure Sitecore has another way of clearing/replenishing the html cache for Sitecore MVC View and Controller renderings — if you know how this works in Sitecore, please share in a comment, or better yet: please share in a blog post — but I went with this just for testing.

There’s not much going on in the above class. It’s just defining the needed methods for its base class to work its magic.

I then had to add the configuration needed by the RazorViewFileWatcher above in the following patch include configuration file:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <settings>
      <setting name="ViewsFolder" value="/Views" />
    </settings>
    <watchers>
      <view>
        <folder ref="settings/setting[@name='ViewsFolder']/@value"/>
        <filter>*.cshtml</filter>
      </view>
    </watchers>
  </sitecore>
</configuration>

As I’ve discussed in this post, FileWatchers in Sitecore are Http Modules — these must be registered under <modules> of <system.webServer> of your Web.config:


<!-- lots of stuff up here -->

<system.webServer>
    <modules runAllManagedModulesForAllRequests="true">

		<!-- stuff here -->
      
		<add type="Sitecore.Sandbox.IO.Watchers.RazorViewFileWatcher, Sitecore.Sandbox" name="SitecoreRazorViewFileWatcher" />

		<!-- stuff here as well -->

	</modules>

	<!-- more stuff down here -->

</system.webServer>

<!-- even more stuff down here -->

Let’s see if this works.

I decided to choose the following Razor file which comes with Web Forms For Marketers 8.1 Update-2:

razor-view-file-watcher-cshtml-1

I first performed a copy and paste of the Razor file into a new file:

razor-view-file-watcher-cshtml-2

I then deleted the new Razor file:

razor-view-file-watcher-cshtml-3

Next, I renamed the Razor file:

razor-view-file-watcher-cshtml-4

When I looked in my Sitecore log file, I saw that all operations were executed:

razor-view-file-watcher-log

If you have any thoughts on this, please drop a comment.

Upload Files via the Sitecore UploadWatcher to a Configuration Specified Media Library Folder

In my previous to last post, I discussed the Sitecore UploadWatcher — a Sitecore.IO.FileWatcher which uploads files to the Media Library when files are dropped into the /upload directory of your Sitecore website root.

Unfortunately, in the “out of the box” solution, files are uploaded directly under the Media Library root (/sitecore/media library). Imagine having to sift through all kinds of Media Library Items and folders just to find the image that you are looking for. Such would be an arduous task at best.

I decided to dig through Sitecore.Kernel.dll to see why this is the case, and discovered why: the FileCreated() method on the Sitecore.Resources.Media.MediaCreator class uses an empty Sitecore.Resources.Media.MediaCreatorOptions instance. In order for the file to be uploaded to a specified location in the Media Library, the Destination property on the Sitecore.Resources.Media.MediaCreatorOptions instance must be set, or it will be uploaded directly to the Media Library root.

Here’s the good news: the FileCreated() method is declared virtual, so why not subclass it and then override this method to include some custom logic to set the Destination property on the MediaCreatorOptions instance?

I did just that in the following class:

using System.IO;

using Sitecore.Configuration;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.IO;
using Sitecore.Pipelines.GetMediaCreatorOptions;
using Sitecore.Resources.Media;

namespace Sitecore.Sandbox.Resources.Media
{
    public class MediaCreator : Sitecore.Resources.Media.MediaCreator
    {
        private string UploadLocation { get; set; }

        public override void FileCreated(string filePath)
        {
            Assert.ArgumentNotNullOrEmpty(filePath, "filePath");
            if (string.IsNullOrWhiteSpace(UploadLocation))
            {
                base.FileCreated(filePath);
                return;
            }

            SetContext();
            lock (FileUtil.GetFileLock(filePath))
            {
                string destination = GetMediaItemDestination(filePath);
                if (FileUtil.IsFolder(filePath))
                {
                    MediaCreatorOptions options = MediaCreatorOptions.Empty;
                    options.Destination = destination;
                    options.Build(GetMediaCreatorOptionsArgs.FileBasedContext);
                    this.CreateFromFolder(filePath, options);
                }
                else
                {
                    MediaCreatorOptions options = MediaCreatorOptions.Empty;
                    options.Destination = destination;
                    long length = new FileInfo(filePath).Length;
                    options.FileBased = (length > Settings.Media.MaxSizeInDatabase) || Settings.Media.UploadAsFiles;
                    options.Build(GetMediaCreatorOptionsArgs.FileBasedContext);
                    this.CreateFromFile(filePath, options);
                }
            }
        }

        protected virtual void SetContext()
        {
            if (Context.Site == null)
            {
                Context.SetActiveSite("shell");
            }
        }

        protected virtual string GetMediaItemDestination(string filePath)
        {
            if(string.IsNullOrWhiteSpace(UploadLocation))
            {
                return null;
            }

            string fileNameNoExtension = Path.GetFileNameWithoutExtension(filePath);
            string itemName = ItemUtil.ProposeValidItemName(fileNameNoExtension);
            return string.Format("{0}/{1}", UploadLocation, itemName);
        }
    }
}

The UploadLocation property in the class above is to be defined in Sitecore Configuration — see the patch include configuration file below — and then populated via the Sitecore Configuration Factory when the class is instantiated (yes, I’m defining this class in Sitecore Configuration as well).

Most of the logic in the FileCreated() method above comes from its base class Sitecore.Resources.Media.MediaCreator. I had to copy and paste most of this code from its base class’ FileCreated() method as I couldn’t just delegate to the base class’ FileCreated() method — I needed to set the Destination property on the MediaCreatorOptions instance.

The Destination property on the MediaCreatorOptions instance is being set to be the UploadLocation plus the Media Library Item name — I determine this full path in the GetMediaItemDestination() method.

Unfortunately, I also had to bring in the SetContext() method from the base class since it’s declared private — this method is needed in the FileCreated() method to ensure we have a context site defined.

Now, we need a way to set an instance of the above in the Creator property on the Sitecore.Sandbox.Resources.Media.MediaProvider instance. Unfortunately, there was no easy way to do this without having to subclass the Sitecore.Sandbox.Resources.Media.MediaProvider class, and then set the Creator property via its constructor:

using Sitecore.Configuration;

namespace Sitecore.Sandbox.Resources.Media
{
    public class MediaProvider : Sitecore.Resources.Media.MediaProvider
    {
        private MediaCreator MediaCreator { get; set; }

        public MediaProvider()
        {
            OverrideMediaCreator();
        }

        protected virtual void OverrideMediaCreator()
        {
            Sitecore.Resources.Media.MediaCreator mediaCreator = GetMediaCreator();
            if (mediaCreator == null)
            {
                return;
            }

            Creator = mediaCreator;
        }

        protected virtual Sitecore.Resources.Media.MediaCreator GetMediaCreator()
        {
            return Factory.CreateObject("mediaLibrary/mediaCreator", false) as MediaCreator;
        }
    }
}

The OverrideMediaCreator() method above tries to get an instance of a Sitecore.Resources.Media.MediaCreator using the Sitecore Configuration Factory — it delegates to the GetMediaCreator() method to get this instance — and then set it on the Creator property of its base class if the MediaCreator obtained from the GetMediaCreator() method isn’t null.

If it is null, it just exits out — there is a default instance created in the Sitecore.Resources.Media.MediaCreator base class, so that one would be used instead.

I then replaced the “out of the box” Sitecore.Resources.Media.MediaProvider with the new one above, and also defined the MediaCreator above in the following patch include configuration file:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <mediaLibrary>
      <mediaProvider patch:instead="mediaProvider[@type='Sitecore.Resources.Media.MediaProvider, Sitecore.Kernel']"
                     type="Sitecore.Sandbox.Resources.Media.MediaProvider, Sitecore.Sandbox" />
      <mediaCreator type="Sitecore.Sandbox.Resources.Media.MediaCreator, Sitecore.Sandbox">
        <UploadLocation>/sitecore/media library/uploaded</UploadLocation>
      </mediaCreator>
  </mediaLibrary>
  </sitecore>
</configuration>

Let’s see how we did.

As you can see, I have an empty uploaded Media Library folder:

upload-watcher-not-uploaded-to-folder

Let’s move an image into the /upload folder of my Sitecore instance:

upload-watcher-upload-to-folder

After reloading the “uploaded” Media Library folder, I see that the image was uploaded to it:

upload-watcher-uploaded-to-folder

I would also to like to mention that this solution will also work if there is no uploaded folder in the Media Library — it will be created during upload process.

If you have any thoughts on this, please share in a comment.

Augment the Sitecore UploadWatcher to Delete Files from the Upload Directory After Uploading to the Media Library

In my previous post I discussed the Sitecore UploadWatcher — a Sitecore.IO.FileWatcher which monitors the /upload directory of your Sitecore instance and uploads files dropped into that directory into the Media Library.

One thing I could never find in the UploadWatcher is functionality to delete files in the /upload directory after they are uploaded into the Media Library — if you know of an “out of the box” way of doing this, please drop a comment.

After peeking into Sitecore.Resources.Media.UploadWatcher using .NET Reflector, I discovered I could add this functionality quite easily since its Created() method — this method handles the uploading of the files into the Media Library by delegating to Sitecore.Resources.Media.MediaManager.Creator.FileCreated() — is overridable. In theory, all I would need to do would be to subclass the UploadWatcher class; override the Created() method; delegate to the base class’ Created() method to handle the upload of the file to the Media Library; then delete the file once the base class’ Create() method was done executing.

After coding, experimenting and refactoring, I built the following class that subclasses Sitecore.Resources.Media.UploadWatcher:

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;

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

namespace Sitecore.Sandbox.Resources.Media
{
    public class CleanupUploadWatcher : UploadWatcher
    {
        private IEnumerable<string> PathsToIgnore { get; set; }

        private IEnumerable<string> FileNamePartsToIgnore { get; set; }

        private bool ShouldDeleteAfterUpload { get; set; }

        public CleanupUploadWatcher()
            : base()
        {
            Initialize();
        }

        private void Initialize()
        {
            IEnumerable<XmlNode> configNodes = GetIgnoreConfigNodes();
            PathsToIgnore = GetPathsToIgnore(configNodes);
            FileNamePartsToIgnore = GetFileNamePartsToIgnore(configNodes);
            ShouldDeleteAfterUpload = GetShouldDeleteAfterUpload();
        }

        protected virtual IEnumerable<XmlNode> GetIgnoreConfigNodes()
        {
            string configNodeRoot = GetConfigNodeRoot();
            if (string.IsNullOrWhiteSpace(configNodeRoot))
            {
                return Enumerable.Empty<XmlNode>();
            }

            XmlNode rootNode = Factory.GetConfigNode(configNodeRoot);
            if (rootNode == null)
            {
                return Enumerable.Empty<XmlNode>();
            }

            return XmlUtil.GetChildNodes(rootNode, true);
        }

        protected virtual string GetConfigNodeRoot()
        {
            return "mediaLibrary/watcher/ignoreList";
        }

        protected virtual IEnumerable<string> GetPathsToIgnore(IEnumerable<XmlNode> nodes)
        {
            if (IsEmpty(nodes))
            {
                return Enumerable.Empty<string>();
            }

            HashSet<string> pathsToIgnore = new HashSet<string>();
            foreach (XmlNode node in nodes)
            {
                string containsValue = XmlUtil.GetAttribute("contains", node);
                if (ShouldAddContainsValue(containsValue, node, "ignorepath"))
                {
                    pathsToIgnore.Add(containsValue);
                }
            }

            return pathsToIgnore;
        }

        protected virtual IEnumerable<string> GetFileNamePartsToIgnore(IEnumerable<XmlNode> nodes)
        {
            if (IsEmpty(nodes))
            {
                return Enumerable.Empty<string>();
            }

            HashSet<string> partsToIgnore = new HashSet<string>();
            foreach (XmlNode node in nodes)
            {
                string containsValue = XmlUtil.GetAttribute("contains", node);
                if (ShouldAddContainsValue(containsValue, node, "ignore"))
                {
                    partsToIgnore.Add(containsValue);
                }
            }

            return partsToIgnore;
        }

        protected virtual bool ShouldAddContainsValue(string containsValue, XmlNode node, string targetNodeName)
        {
            return !string.IsNullOrWhiteSpace(containsValue)
                    && node != null
                    && string.Equals(node.Name, targetNodeName, StringComparison.OrdinalIgnoreCase);
        }

        protected static bool IsEmpty<T>(IEnumerable<T> collection)
        {
            return collection == null || !collection.Any();
        }

        protected override void Created(string filePath)
        {
            Assert.ArgumentNotNullOrEmpty(filePath, "filePath");
            if (!ShouldIgnoreFile(filePath))
            {
                base.Created(filePath);
                DeleteFile(filePath);
            }
        }

        protected virtual bool GetShouldDeleteAfterUpload()
        {
            XmlNode node = Factory.GetConfigNode("watchers/media/additionalSettings");
            if (node == null)
            {
                return false;
            }

            string deleteAfterUploadValue = XmlUtil.GetAttribute("deleteAfterUpload", node);
            if(string.IsNullOrWhiteSpace(deleteAfterUploadValue))
            {
                return false;
            }
            
            bool shouldDelete;
            bool.TryParse(deleteAfterUploadValue, out shouldDelete);
            return shouldDelete;
        }

        protected virtual bool ShouldIgnoreFile(string filePath)
        {
            Assert.ArgumentNotNullOrEmpty(filePath, "filePath");
            foreach (string path in PathsToIgnore)
            {
                if (ContainsSubstring(filePath, path))
                {
                    return true;
                }
            }

            string fileName = Path.GetFileName(filePath);
            foreach (string part in FileNamePartsToIgnore)
            {
                if(ContainsSubstring(fileName, part))
                {
                    return true;
                }
            }

            return false;
        }

        protected virtual bool ContainsSubstring(string value, string substring)
        {
            if(string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(substring))
            {
                return false;
            }

            return value.IndexOf(substring, StringComparison.OrdinalIgnoreCase) > -1;
        }

        protected virtual void DeleteFile(string filePath)
        {
            Assert.ArgumentNotNullOrEmpty(filePath, "filePath");
            if(!ShouldDeleteAfterUpload)
            {
                return;
            }

            try
            {
                FileUtil.Delete(filePath);
            }
            catch(Exception ex)
            {
                Log.Error(ToString(), ex, this);
            }
        }
    }
}

You might thinking “Mike, there is more going on in here than just delegating to the base class’ Created() method and deleting the file. Well, you are correct — I had to do a few things further than what I thought I needed to do, and I’ll explain why.

I had to duplicate the logic — actually I wrote my own logic — to parse the Sitecore configuration which defines the substrings of file names and paths to ignore since these collections on the base UploadWatcher class are private — subclasses cannot access these collections — in order to prevent the code from deleting a file that should be ignored.

I also wedged in configuration with code to turn off the delete functionality if needed.

I then created the following patch include configuration file to hold the configuration setting to turn the delete functionality on/off:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <watchers>
      <media>
        <additionalSettings deleteAfterUpload="true" />
      </media>
    </watchers>
  </sitecore>
</configuration>

I then registered the new CleanupUploadWatcher in the Web.config — please see my previous post which explains why this is needed:

<system.webServer>
    <modules runAllManagedModulesForAllRequests="true">

      <!-- stuff here -->

      <!-- <add type="Sitecore.Resources.Media.UploadWatcher, Sitecore.Kernel" name="SitecoreUploadWatcher" /> -->
      <add type="Sitecore.Sandbox.Resources.Media.CleanupUploadWatcher, Sitecore.Sandbox" name="SitecoreUploadWatcher" />

      <!-- more stuff down here -->

    </modules>

    <!-- and more stuff down here -->
    
<system.webServer>

Let’s see this in action!

As you can see, there is no Media Library Item in the root of my Media Library (/sitecore/media library) — this is where the UploadWatcher uploads the file to:

cleanup-uploadwatcher-media-library-no-file

I then copied an image from a folder on my Desktop to the /upload directory of my Sitecore instance:

cleanup-uploadwatcher-move-file

As you can see above, the image is deleted after it is dropped.

I then went back to my Media Library and refreshed. As you can see here, the file was uploaded:

cleanup-uploadwatcher-media-library-file

If you have any thoughts on this or suggestions on making it better, please share in a comment.

Automagic File Uploads to the Media Library via the “Out of the Box” UploadWatcher in Sitecore

In a previous post I gave a proof of concept on a custom Content Editor Image field which gives content authors the ability to download an image from a URL and save to the downloaded image to the Media Library.

During my testing of this solution, I noticed some odd behavior where I was seeing my image being uploaded twice to the Media Library — one Media Library Item being placed in a folder selected by the user and then another being placed right under the Media Library root Item (/sitecore/media library).

At first I thought I had a bug in my code though could not track it down. I even made many attempts at refactoring the code I had, thinking I was missing something during the debugging process. Still, to no avail, the “bug” was popping its head up during my testing.

However, after a few more passes at refactoring, the “bug” magically disappeared. I figured “hey, I somehow fixed it but have no idea how. Let’s move forward on writing a blog post.”

Unfortunately, the “bug” popped up again when writing my last post. I was quite taken aback given I was repurposing most of the code from the previous solution with the “bug”, and had thought I fixed it. Trust me, I was completely baffled.

Then it dawned on me: I was using the /upload folder in those two solutions — I was placing downloaded images I retrieved from the internet via code into the /upload directory of my Sitecore instance — and remembered Sitecore had an “out of the box” feature which uploads images that are placed into the /upload directory into the Media Library automatically (btw, I remember this feature has existed since I started on Sitecore 9 years ago, so it’s been around for a while; I only forgot about it because I haven’t had any clients use it for many years). Once I changed where I was saving these images on disk, the “bug” magically disappeared.

So that mysterious and baffling — not to mention frustrating — experience brings me to this post. How does Sitecore know there is an image in the /upload directory ready to be uploaded into the Media Library?

Let me introduce you to the UploadWatcher, a Sitecore.IO.FileWatcher which lives in the Sitecore.Resources.Media namespace in Sitecore.Kernel.dll — if you don’t know what a Sitecore FileWatcher is, please see my post on what they are and how to create a custom one in Sitecore.

Like all FileWatchers in Sitecore, the UploadWatcher is defined in the Web.config under /configuration/system.webServer/modules:

upload-watcher-config-0

You might be asking “Mike, why are these defined in the Web.config and not in a patch include configuration file?” Well, all Sitecore FileWatchers are essentially HTTP Modules and, unfortunately, that’s where they must be defined.

How does the UploadWatcher know to monitor the /upload directory in the website root of my Sitecore instance? Well, this is defined in Sitecore configuration under /configuration/sitecore/settings/setting[@name=’MediaFolder’]:

upload-watcher-config-1

You can use a patch configuration file to change the above value if you do not want the UploadWatcher to read from the /upload directory.

The UploadWatcher instance also checks other parts of Sitecore configuration. The following configuration is used specifically by the UploadWatcher:

upload-watcher-config-2-a

It appears you can also change things in the above configuration but I would suggest you don’t unless you have a good reason to do so.

The UploadWatcher also reads the following Sitecore configuration:

upload-watcher-config-3-a

What’s the above configuration all about? Basically, the UploadWatcher instance loads these into a private collection on itself, and uses this collection when checking to ignore files with certain substrings in their file names. You can also include elements that define substrings in a file path to be ignored — you would do so by using <ignorepath contains=”some substring” /> elements. These elements would be siblings of the <ignore /> elements, and are read into a separate private collection on the UploadWatcher instance.

In my next post, I will give an example on how to add functionality to the UploadWatcher. Until then, keep on Sitecoring.