Home » Sheer UI

Category Archives: Sheer UI

Yet Another Post on Sitecore Content Editor Warnings

Over the years, I’ve written posts on adding custom Content Editor Warnings. Content Editor Warnings give visual cues at the top of an Item in the Content Editor to either take action on something about the Item, or to just convey information about the Item — perhaps the Item is read-only, or maybe there is something wrong with the item; you can do all kinds of stuff with these, just use your imagination on what you would like to convey to your content authors.

The way to add these is to create a custom processor for the <getContentEditorWarnings> pipeline in Sitecore. There really isn’t anything more to it.

Moreover, you can even use Sitecore PowerShell Extensions to create these though I did not do this for this post. Sorry, Sitecore MVP Michael West. ๐Ÿ˜‰

Today, I am sharing a recent example of two Content Editor Warnings which I had worked on which convey to content authors they almost have, or have too many child items within a Media Library folder.

Some code on this post reuses service classes found on my previous post where I discussed how to create a custom MasterDataView driven by a custom pipeline; I recommend having a read of that previous post first before proceeding further in order to have complete grounding on some of the service classes I am using in the solution below.

I had to make a tweak to the ITooManySubItemsService service found on my previous post — I had to make the GetNumberOfItemsToStartWarningUser() and GetMaximumNumberOfItemsInFolder() methods public as I needed to get these two values within the Content Editor Warnings in order to display these values in messages to the content authors via the Content Editor Warning. This service determines these values based on a Config Object service which can also be overriden by each individual piece of functionality in my solution (i.e. the maximum number of child items set on the pipeline processor would override the Config Object service’s value):

using Foundation.Validation.Models.TooManySubItems;

namespace Foundation.Validation.Services.TooManySubItems
{
	public interface ITooManySubItemsService
	{
		bool HasAlmostTooManySubItems(TooManySubItemsServiceParameters parameters);

		bool HasTooManySubItems(TooManySubItemsServiceParameters parameters);

		int GetNumberOfItemsToStartWarningUser(TooManySubItemsServiceParameters parameters); // I've added this 

		int GetMaximumNumberOfItemsInFolder(TooManySubItemsServiceParameters parameters); // I've added this 
	}
}

Here is the modified implementation of the interface above. All I did was change the signature on GetNumberOfItemsToStartWarningUser() and GetMaximumNumberOfItemsInFolder() to be public instead of protected:

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

using Sitecore.Data.Items;

using Foundation.Validation.Models.TooManySubItems;
using Foundation.Validation.Services.TooManySubItems;

namespace Feature.Validation.Services.TooManySubItems
{
	public class TooManySubItemsService : ITooManySubItemsService
	{
		private readonly TooManySubItemsSettings _settings; 

		public TooManySubItemsService(TooManySubItemsSettings settings)
		{
			_settings = settings;
		}

		public bool HasAlmostTooManySubItems(TooManySubItemsServiceParameters parameters)
		{
			int numberOfItemsToStartWarningUser = GetNumberOfItemsToStartWarningUser(parameters);
			int maximumNumberOfItemsInFolder = GetMaximumNumberOfItemsInFolder(parameters);
			if (parameters?.Item == null || numberOfItemsToStartWarningUser < 1 || maximumNumberOfItemsInFolder < 1)
			{
				return false;
			}

			IEnumerable<Item> items = GetItemsWithoutTemplateIds(parameters?.Item.Children, parameters?.TemplateIdsToIgnore);
			return items.Count() >= numberOfItemsToStartWarningUser && items.Count() < maximumNumberOfItemsInFolder;
		}

		public bool HasTooManySubItems(TooManySubItemsServiceParameters parameters)
		{
			int maximumNumberOfItemsInFolder = GetMaximumNumberOfItemsInFolder(parameters);
			if (parameters?.Item == null || maximumNumberOfItemsInFolder < 1)
			{
				return false;
			}

			IEnumerable<Item> items = GetItemsWithoutTemplateIds(parameters?.Item.Children, parameters?.TemplateIdsToIgnore);
			return items.Count() >= maximumNumberOfItemsInFolder;
		}

		public int GetNumberOfItemsToStartWarningUser(TooManySubItemsServiceParameters parameters)
		{
			if (parameters == null || _settings == null)
			{
				return 0;
			}

			if (parameters.NumberOfItemsToStartWarningUser > 0)
			{
				return parameters.NumberOfItemsToStartWarningUser;
			}

			return _settings.NumberOfItemsToStartWarningUser;
		}

		public int GetMaximumNumberOfItemsInFolder(TooManySubItemsServiceParameters parameters)
		{
			if (parameters == null || _settings == null)
			{
				return 0;
			}

			if (parameters.MaximumNumberOfItemsInFolder > 0)
			{
				return parameters.MaximumNumberOfItemsInFolder;
			}

			return _settings.MaximumNumberOfItemsInFolder;
		}

		protected virtual IEnumerable<Item> GetItemsWithoutTemplateIds(IEnumerable<Item> items, IEnumerable<string> templateIds)
		{
			if (items == null)
			{
				return Enumerable.Empty<Item>();
			}

			if(templateIds == null || !templateIds.Any())
			{
				return items;
			}

			return items.Where(item => templateIds.All(templateId => templateId != item.TemplateID.ToString())).ToList();
		}
	}
}

The processors which are defined below will give content authors the ability to take action on the Content Editor Warnings. The options are basically just as collection of link text and Sheer UI commands. In this example, content authors are given the option to create new Media Library folders. The following class is used when sourcing these from each processor’s configuration definition (see the Sitecore patch configuration file further down in this post):

namespace Foundation.Kernel.Models.Pipelines.ContentEditorWarnings
{
	public class ContentEditorWarningOption
	{
		public string Text { get; set; }
		
		public string Link { get; set; }
	}
}

Since the two Content Editor Warning processors are doing very similiar things, I abstracted out most of their logic into a base abstract class, and put it hooks for overriding methods on it; this is an example of the Template Method Pattern for you Design Pattern junkies ๐Ÿ˜‰

using System.Collections.Generic;

using Sitecore.Data.Items;
using Sitecore.Pipelines.GetContentEditorWarnings;

using Foundation.Kernel.Models.Pipelines.ContentEditorWarnings;

using Foundation.Validation.Models.TooManySubItems;
using Foundation.Validation.Services.TooManySubItems.Factories;
using Foundation.Validation.Services.TooManySubItems;
using Foundation.Kernel.Services.FeatureToggle;

namespace Feature.ContentEditor.Pipelines.GetContentEditorWarnings
{
	public abstract class BaseTooManyChildItemsWarningProcessor : IFeatureToggleable, ITooManySubItemsFeature
	{
		private readonly ITooManySubItemsFeatureToggleService _tooManySubItemsFeatureToggleService;
		private readonly ITooManySubItemsServiceParametersFactory _tooManySubItemsServiceParametersFactory;
		private readonly ITooManySubItemsService _tooManySubItemsService;

		public bool Enabled { get; set; }

		protected string MediaLibraryBasePath { get; set; }

		public List<string> TemplateIdsToIgnore { get; set; } = new List<string>();

		public int NumberOfItemsToStartWarningUser { get; set; }

		public int MaximumNumberOfItemsInFolder { get; set; }

		protected List<ContentEditorWarningOption> ContentEditorWarningOptions { get; set; } = new List<ContentEditorWarningOption>();

		public BaseTooManyChildItemsWarningProcessor(ITooManySubItemsFeatureToggleService tooManySubItemsFeatureToggleService, ITooManySubItemsServiceParametersFactory tooManySubItemsServiceParametersFactory, ITooManySubItemsService tooManySubItemsService)
		{
			_tooManySubItemsFeatureToggleService = tooManySubItemsFeatureToggleService;
			_tooManySubItemsServiceParametersFactory = tooManySubItemsServiceParametersFactory;
			_tooManySubItemsService = tooManySubItemsService;
		}

		public void Process(GetContentEditorWarningsArgs args)
		{
			if(!IsEnabled() || !IsInMediaLibrary(args?.Item))
			{
				return;
			}

			TooManySubItemsServiceParameters parameters = CreateParameters(args?.Item, this);
			if(parameters == null)
			{
				return;
			}

			if(!ShouldDisplayWarning(parameters))
			{
				return;
			}

			int maximumNumberOfItemsInFolder = GetMaximumNumberOfItemsInFolder(parameters);
			string warningTitle = GetWarningTitle(args, maximumNumberOfItemsInFolder);
			string warningMessage = GetWarningMessage(args, maximumNumberOfItemsInFolder);
			AddWarning(args, warningTitle, warningMessage, ContentEditorWarningOptions);
		}

		protected virtual bool IsEnabled() => _tooManySubItemsFeatureToggleService.IsEnabled(this);

		protected virtual bool IsInMediaLibrary(Item item)
		{
			if(string.IsNullOrWhiteSpace(item.Paths.FullPath))
			{
				return false;
			}

			return item.Paths.FullPath.Contains(MediaLibraryBasePath);
		}

		protected virtual TooManySubItemsServiceParameters CreateParameters(Item item, ITooManySubItemsFeature feature) => _tooManySubItemsServiceParametersFactory.CreateParameters(item, feature);

		protected abstract bool ShouldDisplayWarning(TooManySubItemsServiceParameters parameters);

		protected abstract string GetWarningTitle(GetContentEditorWarningsArgs args, int maximumNumberOfItemsInFolder);

		protected abstract string GetWarningMessage(GetContentEditorWarningsArgs args, int maximumNumberOfItemsInFolder);

		protected virtual int GetMaximumNumberOfItemsInFolder(TooManySubItemsServiceParameters parameters) =>_tooManySubItemsService.GetMaximumNumberOfItemsInFolder(parameters);

		protected virtual bool HasAlmostTooManySubItems(TooManySubItemsServiceParameters parameters) => _tooManySubItemsService.HasAlmostTooManySubItems(parameters);

		protected virtual bool HasTooManySubItems(TooManySubItemsServiceParameters parameters) => _tooManySubItemsService.HasTooManySubItems(parameters);

		protected virtual void AddWarning(GetContentEditorWarningsArgs args, string title, string message, IEnumerable<ContentEditorWarningOption> options)
		{
			if(args == null)
			{
				return;
			}
			
			GetContentEditorWarningsArgs.ContentEditorWarning warning = CreateNewContentEditorWarning(args); 
			if(warning == null)
			{
				return;
			}

			warning.Title = title;
			warning.Text = message;
			AddOptions(warning, options);
		}

		protected virtual GetContentEditorWarningsArgs.ContentEditorWarning CreateNewContentEditorWarning(GetContentEditorWarningsArgs args) => args?.Add();

		protected virtual void AddOptions(GetContentEditorWarningsArgs.ContentEditorWarning warning, IEnumerable<ContentEditorWarningOption> options)
		{
			if(warning == null || options == null)
			{
				return;
			}

			foreach(ContentEditorWarningOption option in options)
			{
				if(string.IsNullOrWhiteSpace(option.Text) || string.IsNullOrWhiteSpace(option.Link)) continue;
				warning.AddOption(option.Text, option.Link);
			}
		}
	}
}

The class above will only display the Content Editor Warning when the feature is enabled (see my previous post where I discuss how this works using Sitecore configuration feature toggles); the item is in the Media Library; and the ShouldDisplayWarning() method returns true — this must be implemented by subclasses of this abstract base class.

If the Content Editor Warning should display, the Content Editor Warning’s title is retrieved via the GetWarningTitle() method, and its message is retrieved via the GetWarningMessage() method — both of these methods must be implemented by its subclasses.

These are then added to the a new GetContentEditorWarningsArgs.ContentEditorWarning instance created from the GetContentEditorWarningsArgs instance with the options defined in the configuration for the processor.

I then created the following interface for the purposes of registering its implementation in the Sitecore IoC container; this is optional as you could just register it with its implementation type being its service type.

using Sitecore.Pipelines.GetContentEditorWarnings;

namespace Feature.ContentEditor.Pipelines.GetContentEditorWarnings
{
	public interface IAlmostTooManyChildItemsWarningProcessor
	{
		void Process(GetContentEditorWarningsArgs args);
	}
}

Here is the implementation of the interface above. This is the processor class to show content authors when they are approaching too many child items:

using Sitecore.Pipelines.GetContentEditorWarnings;

using Foundation.Validation.Models.TooManySubItems;
using Foundation.Validation.Services.TooManySubItems.Factories;
using Foundation.Validation.Services.TooManySubItems;

namespace Feature.ContentEditor.Pipelines.GetContentEditorWarnings
{
	public class AlmostTooManyChildItemsWarningProcessor : BaseTooManyChildItemsWarningProcessor, IAlmostTooManyChildItemsWarningProcessor
	{
		private string AlmostAtMaxiumTitle { get; set; }

		private string AlmostAtMaxiumMessageFormat { get; set; }

		public AlmostTooManyChildItemsWarningProcessor(ITooManySubItemsFeatureToggleService tooManySubItemsFeatureToggleService, ITooManySubItemsServiceParametersFactory tooManySubItemsServiceParametersFactory, ITooManySubItemsService tooManySubItemsService)
			: base(tooManySubItemsFeatureToggleService, tooManySubItemsServiceParametersFactory, tooManySubItemsService)
		{
		}

		protected override bool ShouldDisplayWarning(TooManySubItemsServiceParameters parameters) => HasAlmostTooManySubItems(parameters);

		protected override string GetWarningTitle(GetContentEditorWarningsArgs args, int maximumNumberOfItemsInFolder) => AlmostAtMaxiumTitle;

		protected override string GetWarningMessage(GetContentEditorWarningsArgs args, int maximumNumberOfItemsInFolder) => string.Format(AlmostAtMaxiumMessageFormat, args?.Item?.Children?.Count, maximumNumberOfItemsInFolder);
	}
}

The implementation above is using the AlmostAtMaxiumTitle and AlmostAtMaxiumMessageFormat strings set via the Sitecore Configuration Factory onto the class instance; these are used when displaying messaging to content authors.

Also, this implementation is using the HasAlmostTooManySubItems() method defined on its base class which ultimately makes a call to the ITooManySubItemsService service class to ascertain whether the folder/item almost has too many child items.

Just as I had done above, I created an interface for the other Content Editor Warning, only for the purpose of registering it in the Sitecore IoC container.

using Sitecore.Pipelines.GetContentEditorWarnings;

namespace Feature.ContentEditor.Pipelines.GetContentEditorWarnings
{
	public interface ITooManyChildItemsWarningProcessor
	{
		void Process(GetContentEditorWarningsArgs args);
	}
}

Here is the implementation of the interface above:

using Sitecore.Pipelines.GetContentEditorWarnings;

using Foundation.Validation.Models.TooManySubItems;
using Foundation.Validation.Services.TooManySubItems.Factories;
using Foundation.Validation.Services.TooManySubItems;

namespace Feature.ContentEditor.Pipelines.GetContentEditorWarnings
{
	public class TooManyChildItemsWarningProcessor : BaseTooManyChildItemsWarningProcessor, ITooManyChildItemsWarningProcessor
	{
		private string AtMaxiumTitle { get; set; }

		private string AtMaxiumMessageFormat { get; set; }

		public TooManyChildItemsWarningProcessor(ITooManySubItemsFeatureToggleService tooManySubItemsFeatureToggleService, ITooManySubItemsServiceParametersFactory tooManySubItemsServiceParametersFactory, ITooManySubItemsService tooManySubItemsService)
			: base(tooManySubItemsFeatureToggleService, tooManySubItemsServiceParametersFactory, tooManySubItemsService)
		{
		}

		protected override bool ShouldDisplayWarning(TooManySubItemsServiceParameters parameters) => HasTooManySubItems(parameters);

		protected override string GetWarningTitle(GetContentEditorWarningsArgs args, int maximumNumberOfItemsInFolder) => AtMaxiumTitle;

		protected override string GetWarningMessage(GetContentEditorWarningsArgs args, int maximumNumberOfItemsInFolder) => string.Format(AtMaxiumMessageFormat, args?.Item?.Children?.Count, maximumNumberOfItemsInFolder);
	}
}

The class above is using the AtMaxiumTitle and AtMaxiumMessageFormat strings set via the Sitecore Configuration Factory from the processor’s configuration definition (see the Sitecore configuration patch file below for both processor definitions), and uses the HasTooManySubItems() method defined on the base class which also just delegates to the ITooManySubItemsService service class.

I then registered everything above in the following Sitecore patch configuration file:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/" xmlns:role="http://www.sitecore.net/xmlconfig/role/">
	<sitecore>
		<pipelines>
			<getContentEditorWarnings>
				<processor type="Feature.ContentEditor.Pipelines.GetContentEditorWarnings.IAlmostTooManyChildItemsWarningProcessor, Feature.ContentEditor" patch:before="processor[1]" resolve="true">
					<Enabled>true</Enabled>
					<MediaLibraryBasePath>/sitecore/media library/</MediaLibraryBasePath>
					<AlmostAtMaxiumTitle>This Item Almost Has Too Many Subitems Underneath It!</AlmostAtMaxiumTitle>
					<AlmostAtMaxiumMessageFormat>This item has {0} media libary items underneath it!  The maximum number of subitems allowed is {1}. Consider creating a new media library folder at this time.</AlmostAtMaxiumMessageFormat>
					<!-- By setting these, you can override the default values set for the entire feature set
										<NumberOfItemsToStartWarningUser>95</NumberOfItemsToStartWarningUser>
										<MaximumNumberOfItemsInFolder>100</MaximumNumberOfItemsInFolder>
					-->
					<ContentEditorWarningOptions hint="list">
						<Option type="Foundation.Kernel.Models.Pipelines.ContentEditorWarnings.ContentEditorWarningOption, Foundation.Kernel">
							<Text>New Media Folder</Text>
							<Link>media:newfolder</Link>
						</Option>
					</ContentEditorWarningOptions>
				</processor>
				<processor type="Feature.ContentEditor.Pipelines.GetContentEditorWarnings.ITooManyChildItemsWarningProcessor, Feature.ContentEditor" patch:before="processor[1]" resolve="true">
					<Enabled>true</Enabled>
					<MediaLibraryBasePath>/sitecore/media library/</MediaLibraryBasePath>
					<AtMaxiumTitle>This Item Has Too Many Subitems Underneath It!</AtMaxiumTitle>
					<AtMaxiumMessageFormat>This item has {0} media libary items underneath it! The maximum number of subitems allowed is {1}. It's time to create a new media library folder for more items to upload.</AtMaxiumMessageFormat>
					<!-- By setting these, you can override the default values set for the entire feature set
										<NumberOfItemsToStartWarningUser>95</NumberOfItemsToStartWarningUser>
										<MaximumNumberOfItemsInFolder>100</MaximumNumberOfItemsInFolder>
					-->
					<ContentEditorWarningOptions hint="list">
						<Option type="Foundation.Kernel.Models.Pipelines.ContentEditorWarnings.ContentEditorWarningOption, Foundation.Kernel">
							<Text>New Media Folder</Text>
							<Link>media:newfolder</Link>
						</Option>
					</ContentEditorWarningOptions>
				</processor>
			</getContentEditorWarnings>	
		</pipelines>
		<services>
			<register
				serviceType="Feature.ContentEditor.Pipelines.GetContentEditorWarnings.IAlmostTooManyChildItemsWarningProcessor, Feature.ContentEditor"
				implementationType="Feature.ContentEditor.Pipelines.GetContentEditorWarnings.AlmostTooManyChildItemsWarningProcessor, Feature.ContentEditor"
				lifetime="Singleton" />
			<register
				serviceType="Feature.ContentEditor.Pipelines.GetContentEditorWarnings.ITooManyChildItemsWarningProcessor, Feature.ContentEditor"
				implementationType="Feature.ContentEditor.Pipelines.GetContentEditorWarnings.TooManyChildItemsWarningProcessor, Feature.ContentEditor"
				lifetime="Singleton" />
		</services>
	</sitecore>
</configuration>

Let’s see what these two processors do.

When looking at an Item which is approaching too many child items, we see the following:

When we have a look at an Item which has more than the maximum number set for child items, we see the following:

Please drop a comment if you have questions/comments, or would like to share how you’ve used Content Editor Warnings on projects you have worked on.

Magically Register Sitecore Configuration Objects in the Sitecore IoC Container using a custom Attribute

About a year and half ago, Sitecore MVP Alan Coates blogged about having Sitecore Configuration Objects (aka Config Objects) being sourced from the Sitecore IoC container, and then injected into service classes that need them. He did this on a theme of Helix Principles but in reality, the ability to create Config Objects has existed since the Configuration Factory was released on version 6.x — don’t ask me which exact version the Configuration Factory was released as this was many projects, many grey hairs, and many country moves ago albeit it’s not something new (you can search through some of my vintage blog posts all the way back to the beginning where I have used these), and the ability to stick these into the Sitecore IoC container has existed since version 8.2 — this is when Sitecore rolled out its IoC container with native Dependency Injection support.

However, he was correct on the statement that it is something which is often overlooked as I continue to ยฏ\_(ใƒ„)_/ยฏ (/shrug on Slack ๐Ÿ˜‰ ) — or maybe even (ใƒŽเฒ ็›Šเฒ )ใƒŽๅฝกโ”ปโ”โ”ป — when I see solutions where everyone dumps everything configuration-driven in a Sitecore setting. I hope this blog post will further reinforce the practice of employing config objects in your solutions — or hopefully make the Sitecore settings junkies adopt this alternative approach instead — especially when I’m going to discuss how I’ve been wiring-up these config objects into the Sitecore IoC container using a custom Attribute decorating classes which represent these Config Objects along with a Foundation layer module configurator to put these into the IoC container; this is an approach I have been doing for the past 2 years.

Virtually all of my posts in 2018 — see my last post of 2018 for an example — created and stuck Config Objects into the IoC container for injection into servlice classes where needed. I had done all of these using code which created and registered these into the IoC container in a Configurator for a particular Helix layer module. At the time, it felt a bit awkward to me as I felt the knowledge of the configuration path to these was a bit removed from the classes which represent these objects, so I came up with the following way to define these configuration paths closer to the class definitions of the Config Objects (you can’t really get any closer than having the path be somewhere on the class, and this is done through a custom Attribute).

First, we need to custom Attribute which will decorate classes which represent the Config Objects:

using System;

using Foundation.DependencyInjection.Enums;

namespace Foundation.DependencyInjection
{
    [AttributeUsage(AttributeTargets.Class, Inherited = false)]
    public class ServiceConfigObjectAttribute : Attribute
    {
        public Lifetime Lifetime { get; set; } = Lifetime.Singleton;

		public string ConfigPath { get; set; }

		public Type ServiceType { get; set; }
	}
}

This solution involves assembly scanning using wildcards. I adapted Kam Figy‘s solution around registering MvC Controllers into the IoC container (see the end of https://kamsar.net/index.php/2016/08/Dependency-Injection-in-Sitecore-8-2/ for this) to make some of this magic happen but created a class with interface to abstract some of this out:

using System.Collections.Generic;
using System.Reflection;

namespace Foundation.DependencyInjection.Services.Assemblies
{
    public interface IAssemblyRepository
    {
        IEnumerable<Assembly> GetAssemblies(IEnumerable<string> assemblyFilters);
        
        IEnumerable<Assembly> GetAssemblies();

        Assembly GetCallingAssembly();
    }
}
using System;
using System.Linq;
using System.Reflection;
using System.Collections.Generic;
using System.Text.RegularExpressions;

namespace Foundation.DependencyInjection.Services.Assemblies
{
    public class AssemblyRepository : IAssemblyRepository
    {
        public IEnumerable<Assembly> GetAssemblies(IEnumerable<string> assemblyFilters)
        {
            var assemblyNames = new HashSet<string>(assemblyFilters.Where(filter => !filter.Contains('*')));
            var wildcardNames = assemblyFilters.Where(filter => filter.Contains('*')).ToArray();

            return GetAssemblies().Where(assembly =>
            {
                var nameToMatch = assembly.GetName().Name;
                if (assemblyNames.Contains(nameToMatch)) return true;

                return wildcardNames.Any(wildcard => IsWildcardMatch(nameToMatch, wildcard));
            }).ToList();
        }

        protected virtual bool IsWildcardMatch(string input, string wildcards)
        {
            return Regex.IsMatch(input, "^" + Regex.Escape(wildcards).Replace("\\*", ".*").Replace("\\?", ".") + "$", RegexOptions.IgnoreCase);
        }

        public IEnumerable<Assembly> GetAssemblies() => AppDomain.CurrentDomain.GetAssemblies();

        public Assembly GetCallingAssembly() => Assembly.GetCallingAssembly();
    }
}

The class above returns a collection of Assemblies which match a collection of wildcards passed to it.

I then created a factory class with interface to create the class instance above:

namespace Foundation.DependencyInjection.Services.Assemblies.Factories
{
    public interface IAssemblyRepositoryFactory
    {
        IAssemblyRepository CreateAssemblyRepository();
    }
}
namespace Foundation.DependencyInjection.Services.Assemblies.Factories
{
    public class AssemblyRepositoryFactory : IAssemblyRepositoryFactory
    {
        public IAssemblyRepository CreateAssemblyRepository() => new AssemblyRepository();
    }
}

The class above has a method which creates the instance of the AssemblyRepository class instance as its interface.

Next, I create an enumeration to define the lifetimes of the service classes — I can’t think of a reason why you would want these Config Objects to be anything but a Singleton but who knows, you might have a reason:

namespace Foundation.DependencyInjection.Enums
{
    public enum Lifetime
    {
        Transient,
        Singleton,
        Scoped
    }
}

Following this, I created some Extension Methods of IServiceCollection so I can find all classes decorated with the ServiceConfigObjectAttribute class defined above; use the Sitecore Configuration Factory service to create their instances; and register them in the IoC container with the appropriate service type — if none is provided, it’ll use the class type — and lifetime defined:

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Web.Http;
using System.Web.Mvc;

using Microsoft.Extensions.DependencyInjection;

using Sitecore.Abstractions;

using Foundation.DependencyInjection.Enums;
using Foundation.DependencyInjection.Services.Assemblies;
using Foundation.DependencyInjection.Services.Assemblies.Factories;

namespace Foundation.DependencyInjection.Extensions
{
    public static class ServiceCollectionExtensions
    {
		private static readonly IAssemblyRepository _assemblyRepository;

        static ServiceCollectionExtensions()
        {
            _assemblyRepository = CreateAssemblyRepository();
        }

        private static IAssemblyRepository CreateAssemblyRepository() => CreateAssemblyRepositoryFactory()?.CreateAssemblyRepository();

        private static IAssemblyRepositoryFactory CreateAssemblyRepositoryFactory() => new AssemblyRepositoryFactory();
		
		public static void AddClassesWithServiceConfigObjectAttribute(this IServiceCollection serviceCollection, params string[] assemblyFilters)
		{
			var assemblies = GetAssemblies(assemblyFilters);
			serviceCollection.AddClassesWithServiceConfigObjectAttribute(assemblies);
		}

		public static Assembly[] GetAssemblies(IEnumerable<string> assemblyFilters) => _assemblyRepository.GetAssemblies(assemblyFilters)?.ToArray();
		
		public static void AddClassesWithServiceConfigObjectAttribute(this IServiceCollection serviceCollection, params Assembly[] assemblies)
        {
            var typesWithAttributes = assemblies
                .Where(assembly => !assembly.IsDynamic)
                .SelectMany(GetExportedTypes)
                .Where(type => !type.IsAbstract && !type.IsGenericTypeDefinition)
                .Select(type => new { type.GetCustomAttribute<ServiceConfigObjectAttribute>()?.ServiceType, ImplementationType = type, type.GetCustomAttribute<ServiceConfigObjectAttribute>()?.ConfigPath, type.GetCustomAttribute<ServiceConfigObjectAttribute>()?.Lifetime })
                .Where(t => t.Lifetime != null);

            foreach (var type in typesWithAttributes)
            {
                AddConfigObject(serviceCollection, type.ServiceType == null ? type.ImplementationType : type.ServiceType, type.Lifetime.Value, type.ConfigPath);
            }
        }
		
		private static void AddConfigObject(IServiceCollection serviceCollection, Type serviceType, Lifetime lifetime, string configPath)
        {
            if (serviceCollection == null || serviceType == null || string.IsNullOrWhiteSpace(configPath))
            {
                return;
            }

            serviceCollection.Add(CreateServiceDescriptor(serviceType, provider => CreateConfigObject(provider, configPath), GetServiceLifetime(lifetime)));
        }
		
		private static ServiceDescriptor CreateServiceDescriptor(Type serviceType, Func<IServiceProvider, object> factory, ServiceLifetime lifetime) => new ServiceDescriptor(serviceType, factory, lifetime);
		
		private static object CreateConfigObject(IServiceProvider provider, string configPath)
        {
            BaseFactory factory = provider.GetService<BaseFactory>();
            return factory.CreateObject(configPath, true);
        }
		
		private static ServiceLifetime GetServiceLifetime(Lifetime lifetime)
        {
            if (lifetime == Lifetime.Singleton)
            {
                return ServiceLifetime.Singleton;
            }

            if (lifetime == Lifetime.Transient)
            {
                return ServiceLifetime.Transient;
            }

            return ServiceLifetime.Transient;
        }
	}
}

Now, we need to have a configurator to call the extension method in the class above. I first defined a base configurator which will have a method to return the assembly wildcards — it’s virtual just in case you need to target different assemblies (I’m sure there’s a better place to put these wildcards but I stuck them here for now):

namespace Foundation.DependencyInjection.Configurators
{
    public abstract class BaseAssemblyFiltersServicesConfigurator : IServicesConfigurator
    {
		private static readonly string[] _defaultAssemblyFilters = new string[] { "Foundation.*", "Feature.*" };

		protected virtual string[] GetAssemblyFilters() => _defaultAssemblyFilters;
    }
}

The following configurator inherits the base configurator above, and just calls the AddClassesWithServiceConfigObjectAttribute() extension method defined in the ServiceCollectionExtensions class above, and passes the assembly wildcard collection defined in the BaseAssemblyFiltersServicesConfigurator class above:

using Microsoft.Extensions.DependencyInjection;

using Foundation.DependencyInjection.Extensions;

namespace Foundation.DependencyInjection.Configurators
{
    public class ServiceConfigObjectAttributeServicesConfigurator : BaseAssemblyFiltersServicesConfigurator
	{
		public override void Configure(IServiceCollection serviceCollection) => serviceCollection.AddClassesWithServiceConfigObjectAttribute(GetAssemblyFilters());
	}
}

I then registered the configurator above in a Sitecore patch configuration file:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/" xmlns:role="http://www.sitecore.net/xmlconfig/role/">
  <sitecore>
    <services>
        <configurator type= "Foundation.DependencyInjection.Configurators.ServiceConfigObjectAttributeServicesConfigurator, Foundation.DependencyInjection"/>
    </services>
  </sitecore>
</configuration>

Alright, now that we have all of this out of the way, let’s see how we can use all of the stuff above.

I created all of the following for a future blog post — and also for a project I had recently worked on — but I’ll show these now to demonstrate how this all works, and to also save me time on writing that future post ๐Ÿ˜‰

The following class is a Config Object which has the name of a Sheer UI client command, and a delay value for that command to be invoked inside the Sitecore client:

using Foundation.DependencyInjection;
using Foundation.DependencyInjection.Enums;

namespace Foundation.Kernel.Models.Client
{
	[ServiceConfigObject(ConfigPath = "moduleSettings/foundation/kernel/clientServiceSettings", Lifetime = Lifetime.Singleton)]
	public class ClientServiceSettings
	{
		public string LoadItemClientCommandName { get; set; }

		public int LoadItemClientCommandDelay { get; set; }
	}
}

Here’s what it looks like in a Sitecore patch configuration file:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/" xmlns:role="http://www.sitecore.net/xmlconfig/role/">
  <sitecore>
	  <moduleSettings>
		  <foundation>
			  <kernel>
				  <clientServiceSettings type="Foundation.Kernel.Models.Client.ClientServiceSettings, Foundation.Kernel" singleInstance="true">
					  <LoadItemClientCommandName>item:load</LoadItemClientCommandName>
					  <LoadItemClientCommandDelay>1</LoadItemClientCommandDelay>
				  </clientServiceSettings>
			  </kernel>
		  </foundation>
	  </moduleSettings>
  </sitecore>
</configuration>

Let’s define another Config Object but something which is a little more “complex” than the example above.

I created the following class to represent a Sheer UI command — ultimately, these will be stored in a Dictionary so I can find a command format by key:

namespace Foundation.Kernel.Models.Client
{
	public class Command
	{
		public string Name { get; set; }

		public string CommandFormat { get; set; }
	}
}

Next, I need a service type/implementation to manage Command class instances above, and ultimately wrap a Dictionary containing information for these Commands:

using Foundation.Kernel.Models.Client;

namespace Foundation.Kernel.Services.Client
{
	public interface IClientCommandService
	{
		string GetClientCommand(string name, params string[] arguments);

		Command GetCommand(string name);
	}
}

Here’s the implementation of the interface above:

using System.Collections.Generic;

using Foundation.DependencyInjection;
using Foundation.DependencyInjection.Enums;

using Foundation.Kernel.Models.Client;

namespace Foundation.Kernel.Services.Client
{
	[ServiceConfigObject(ConfigPath = "moduleSettings/foundation/kernel/clientCommandService ", ServiceType = typeof(IClientCommandService), Lifetime = Lifetime.Singleton)]
	public class ClientCommandService : IClientCommandService
	{
		private readonly IDictionary<string, Command> _commands = new Dictionary<string, Command>();

		protected void AddClientCommand(string key, Command command)
		{
			if(string.IsNullOrWhiteSpace(key) || command == null)
			{
				return;
			}

			_commands[key] = command;
		}

		public string GetClientCommand(string name, params string[] arguments)
		{
			string commandFormat = GetCommandFormat(name);
			if(string.IsNullOrWhiteSpace(commandFormat))
			{
				return string.Empty;
			}

			return string.Format(commandFormat, arguments);
		}

		protected virtual string GetCommandFormat(string name) => GetCommand(name)?.CommandFormat;

		public Command GetCommand(string name) => _commands[name];
	}
}

So if I were to call the GetClientCommand() method on the service class above like this:

IClientCommandService clientCommandService; // make pretend this was injected in a class that's using it
clientCommandService.GetClientCommand("item:load") // this would return item:load(id={0}) so that we can do a string.Format() on it while providing an Item ID (this is used to load (or reload) an Item in the Content Editor

We would get a item:load(id={0}) so that we could supply an Item ID to replace {0}.

Here’s the Sitecore patch configuration file which represents the Config Object above:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/" xmlns:role="http://www.sitecore.net/xmlconfig/role/">
  <sitecore>
	  <services>
		  <configurator type="Foundation.Kernel.Configurators.KernelConfigurator, Foundation.Kernel"/>
	  </services>
	  <moduleSettings>
		  <foundation>
			  <kernel>
				  <clientCommandService type="Foundation.Kernel.Services.Client.ClientCommandService, Foundation.Kernel" singleInstance="true">
					  <Commands hint="list:AddClientCommand">
						  <command key="item:load" type="Foundation.Kernel.Models.Client.Command, Foundation.Kernel">
							  <Name>$(key)</Name>
							  <CommandFormat>item:load(id={0})</CommandFormat>
						  </command>
					  </Commands>
				  </clientCommandService>
			  </kernel>
		  </foundation>
	  </moduleSettings>
  </sitecore>
</configuration>

Here’s what both Config Objects looking like on /sitecore/admin/showservicesconfig.aspx after all the code above runs:

Registered in IoC container

Now that the two configuration objects are in the IoC container, we can inject them into the follow service. Here’s the interface of that service:

using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Web.UI.HtmlControls;

namespace Foundation.Kernel.Services.Client
{
	public interface IClientService
	{
		void RefreshItem(Item item);

		void RefreshItem(ID itemId);

		ClientCommand Timer(string eventName, int delay);

		void Alert(string message);
	}
}

Here’s the implementation the service:

using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Web.UI.HtmlControls;

using Foundation.Kernel.Services.Client.SheerUI;
using Foundation.Kernel.Services.UniqueIdentifier;
using Foundation.Kernel.Models.Client;

namespace Foundation.Kernel.Services.Client
{
	public class ClientService : IClientService
	{
		private readonly ClientServiceSettings _settings;
		private readonly IIDService _idService;
		private readonly ISheerResponseService _sheerResponseService;
		private readonly IClientCommandService _clientCommandService;
		private readonly IClientResponseService _clientResponseService;
		
		public ClientService(ClientServiceSettings settings, IIDService idService, ISheerResponseService sheerResponseService, IClientCommandService clientCommandService, IClientResponseService clientResponseService)
		{
			_settings = settings; // config object was injected here
			_idService = idService; 
			_sheerResponseService = sheerResponseService;
			_clientCommandService = clientCommandService; // another config object injected here 
			_clientResponseService = clientResponseService;
		}

		public void RefreshItem(Item item) => RefreshItem(item?.ID);

		public void RefreshItem(ID itemId)
		{
			if(IsNullOrEmpty(itemId))
			{
				return;
			}

			string command = GetClientCommand(GetLoadItemClientCommandName(), itemId.ToString());
			if(string.IsNullOrWhiteSpace(command))
			{
				return;
			}

			Timer(command, GetLoadItemClientCommandDelay());
		}

		protected virtual bool IsNullOrEmpty(ID id) => _idService.IsNullOrEmpty(id);

		protected virtual string GetLoadItemClientCommandName() => _settings.LoadItemClientCommandName;

		protected virtual string GetClientCommand(string name, params string[] arguments) => _clientCommandService.GetClientCommand(name, arguments);

		protected virtual int GetLoadItemClientCommandDelay() => _settings.LoadItemClientCommandDelay;

		public ClientCommand Timer(string eventName, int delay) => _clientResponseService.Timer(eventName, delay);

		public void Alert(string message) => _sheerResponseService.Alert(message);
	}
}

The class above consumes instances of the ClientServiceSettings and IClientCommandService Config Objects along with other injected services which I am omitting for brevity – I believe I may have covered these other service classes in previous blog posts, and may cover some others in future blog posts — but the basic idea of this class is to wrap methods to functionality of Sheer UI to send Alert messages, refresh an item in the content tree of the Contend Editor, or to invoke another client command (via the Timer() method).

Most of my posts moving forward will use the ServiceConfigObjectAttribute above so be sure to understand what’s going on here in order to fully know what’s going on in those future posts.

magic

Download Images and Save to the Media Library Via a Custom Content Editor Image Field in Sitecore

Yesterday evening — a Friday evening by the way (what, you don’t code on Friday evenings? ๐Ÿ˜‰ ) — I wanted to have a bit of fun by building some sort of customization in Sitecore but was struggling on what to build.

After about an hour of pondering, it dawned on me: I was determined to build a custom Content Editor Image field that gives content authors the ability to download images from a supplied URL; save the image to disk; upload the image into the Media Libary; and then set it on a custom Image field of an Item.

I’m sure someone has built something like this in the past and may have even uploaded a module that does this to the Sitecore Marketplace — I didn’t really look into whether this had already been done before since I wanted to have some fun by taking on the challenge. What follows is the fruit of that endeavor.

Before I move forward, I would like to caution you on using the code that follows — I have not rigorously tested this code at all so use at your own risk.

Before I began coding, I thought about how I wanted to approach this challenge. I decided I would build a custom Sitecore pipeline to handle this code. Why? Well, quite frankly, it gives you flexibility on customization, and also native Sitecore code is hugely composed of pipelines — why deviate from the framework?

First, I needed a class whose instances would serve as the custom pipeline’s arguments object. The following class was built for that:

using Sitecore.Data;
using Sitecore.Pipelines;

namespace Sitecore.Sandbox.Pipelines.DownloadImageToMediaLibrary
{
    public class DownloadImageToMediaLibraryArgs : PipelineArgs
    {
        public Database Database { get; set; }

        public string ImageFileName { get; set; }

        public string ImageFilePath { get; set; }

        public string ImageItemName { get; set; }

        public string ImageUrl { get; set; }

        public string MediaId { get; set; }

        public string MediaLibaryFolderPath { get; set; }

        public string MediaPath { get; set; }

        public bool FileBased { get; set; }

        public bool IncludeExtensionInItemName { get; set; }

        public bool OverwriteExisting { get; set; }

        public bool Versioned { get; set; }
    }
}

I didn’t just start off with all of the properties you see on this class — it was an iterative process where I had to go back, add more and even remove some that were no longer needed. You will see why I have these on it from the code below.

I decided to employ the Template method pattern in this code, and defined the following abstract base class which all processors of my custom pipeline will sub-class:

using Sitecore.Diagnostics;

namespace Sitecore.Sandbox.Pipelines.DownloadImageToMediaLibrary
{
    public abstract class DownloadImageToMediaLibraryProcessor
    {
        public void Process(DownloadImageToMediaLibraryArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            if(!CanProcess(args))
            {
                AbortPipeline(args);
                return;
            }

            Execute(args);
        }

        protected abstract bool CanProcess(DownloadImageToMediaLibraryArgs args);

        protected virtual void AbortPipeline(DownloadImageToMediaLibraryArgs args)
        {
            args.AbortPipeline();
        }

        protected abstract void Execute(DownloadImageToMediaLibraryArgs args);
    }
}

All processors of the custom pipeline will have to implement the CanProcess and Execute methods above, and also have the ability to redefine the AbortPipeline method if needed.

The main magic for all processors happen in the Process method above — if the processor can process the data supplied via the arguments object, then it will do so using the Execute method. Otherwise, the pipeline will be aborted via the AbortPipeline method.

The following class serves as the first processor of the custom pipeline.

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

namespace Sitecore.Sandbox.Pipelines.DownloadImageToMediaLibrary
{
    public class SetProperties : DownloadImageToMediaLibraryProcessor
    {
        private string UploadDirectory { get; set; }

        protected override bool CanProcess(DownloadImageToMediaLibraryArgs args)
        {
            Assert.IsNotNullOrEmpty(UploadDirectory, "UploadDirectory must be set in configuration!");
            Assert.IsNotNull(args.Database, "args.Database must be supplied!");
            return !string.IsNullOrWhiteSpace(args.ImageUrl)
                && !string.IsNullOrWhiteSpace(args.MediaLibaryFolderPath);
        }

        protected override void Execute(DownloadImageToMediaLibraryArgs args)
        {
            args.ImageFileName = GetFileName(args.ImageUrl);
            args.ImageItemName = GetImageItemName(args.ImageUrl);
            args.ImageFilePath = GetFilePath(args.ImageFileName);
        }

        protected virtual string GetFileName(string url)
        {
            Assert.ArgumentNotNullOrEmpty(url, "url");
            return FileUtil.GetFileName(url);
        }

        protected virtual string GetImageItemName(string url)
        {
            Assert.ArgumentNotNullOrEmpty(url, "url");
            string fileNameNoExtension = GetFileNameNoExtension(url);
            if(string.IsNullOrWhiteSpace(fileNameNoExtension))
            {
                return string.Empty;
            }

            return ItemUtil.ProposeValidItemName(fileNameNoExtension);
        }

        protected virtual string GetFileNameNoExtension(string url)
        {
            Assert.ArgumentNotNullOrEmpty(url, "url");
            return FileUtil.GetFileNameWithoutExtension(url);
        }

        protected virtual string GetFilePath(string fileName)
        {
            Assert.ArgumentNotNullOrEmpty(fileName, "fileName");
            return string.Format("{0}/{1}", FileUtil.MapPath(UploadDirectory), fileName);
        }
    }
}

Instances of the above class will only run if an upload directory is supplied via configuration (see the patch include configuration file down below); a Sitecore Database is supplied (we have to upload this image somewhere); an image URL is supplied (can’t download an image without this); and a Media Library folder is supplied (where are we storing this image?).

The Execute method then sets additional properties on the arguments object that the next processors will need in order to complete their tasks.

The following class serves as the second processor of the custom pipeline. This processor will download the image from the supplied URL:

using System.Net;

namespace Sitecore.Sandbox.Pipelines.DownloadImageToMediaLibrary
{
    public class DownloadImage : DownloadImageToMediaLibraryProcessor
    {
        protected override bool CanProcess(DownloadImageToMediaLibraryArgs args)
        {
            return !string.IsNullOrWhiteSpace(args.ImageUrl)
                && !string.IsNullOrWhiteSpace(args.ImageFilePath);
        }

        protected override void Execute(DownloadImageToMediaLibraryArgs args)
        {
            using (WebClient client = new WebClient())
            {
                client.DownloadFile(args.ImageUrl, args.ImageFilePath);
            }
        }
    }
}

The processor instance of the above class will only execute when an image URL is supplied and a location on the file system is given — this is the location on the file system where the image will live before being uploaded into the Media Library.

If all checks out, the image is downloaded from the given URL into the specified location on the file system.

The next class serves as the third processor of the custom pipeline. This processor will upload the image on disk to the Media Library:

using System.IO;

using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Resources.Media;
using Sitecore.Sites;

namespace Sitecore.Sandbox.Pipelines.DownloadImageToMediaLibrary
{
    public class UploadImageToMediaLibrary : DownloadImageToMediaLibraryProcessor
    {
        private string Site { get; set; }

        protected override bool CanProcess(DownloadImageToMediaLibraryArgs args)
        {
            Assert.IsNotNullOrEmpty(Site, "Site must be set in configuration!");
            return !string.IsNullOrWhiteSpace(args.MediaLibaryFolderPath)
                && !string.IsNullOrWhiteSpace(args.ImageItemName)
                && !string.IsNullOrWhiteSpace(args.ImageFilePath)
                && args.Database != null;
        }

        protected override void Execute(DownloadImageToMediaLibraryArgs args)
        {
            MediaCreatorOptions options = new MediaCreatorOptions
            {
                Destination = GetMediaLibraryDestinationPath(args),
                FileBased = args.FileBased,
                IncludeExtensionInItemName = args.IncludeExtensionInItemName,
                OverwriteExisting = args.OverwriteExisting,
                Versioned = args.Versioned,
                Database = args.Database
            };
            
            MediaCreator creator = new MediaCreator();
            MediaItem mediaItem;
            using (SiteContextSwitcher switcher = new SiteContextSwitcher(GetSiteContext()))
            {
                using (FileStream fileStream = File.OpenRead(args.ImageFilePath))
                {
                    mediaItem = creator.CreateFromStream(fileStream, args.ImageFilePath, options);
                }
            }
            
            if (mediaItem == null)
            {
                AbortPipeline(args);
                return;
            }
            
            args.MediaId = mediaItem.ID.ToString();
            args.MediaPath = mediaItem.MediaPath;
        }

        protected virtual SiteContext GetSiteContext()
        {
            SiteContext siteContext = SiteContextFactory.GetSiteContext(Site);
            Assert.IsNotNull(siteContext, string.Format("The site: {0} does not exist!", Site));
            return siteContext;
        }

        protected virtual string GetMediaLibraryDestinationPath(DownloadImageToMediaLibraryArgs args)
        {
            return string.Format("{0}/{1}", args.MediaLibaryFolderPath, args.ImageItemName);
        }
    }
}

The processor instance of the class above will only run when we have a Media Library folder location; an Item name for the image; a file system path for the image; and a Database to upload the image to. I also ensure a “site” is supplied via configuration so that I can switch the site context — when using the default of “shell”, I was being brought to the image Item in the Media Library after it was uploaded which was causing the image not to be set on the custom Image field on the Item.

If everything checks out, we upload the image to the Media Library in the specified location.

The next class serves as the last processor of the custom pipeline. This processor just deletes the image from the file system (why keep it around since we are done with it?):

using Sitecore.IO;

namespace Sitecore.Sandbox.Pipelines.DownloadImageToMediaLibrary
{
    public class DeleteImageFromFileSystem : DownloadImageToMediaLibraryProcessor
    {
        protected override bool CanProcess(DownloadImageToMediaLibraryArgs args)
        {
            return !string.IsNullOrWhiteSpace(args.ImageFilePath)
                && FileUtil.FileExists(args.ImageFilePath);
        }

        protected override void Execute(DownloadImageToMediaLibraryArgs args)
        {
            FileUtil.Delete(args.ImageFilePath);
        }
    }
}

The processor instance of the class above can only delete the image if its path is supplied and the file exists.

If all checks out, the image is deleted.

The next class is the class that serves as the custom Image field:

using System;
using System.Net;

using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Pipelines;
using Sitecore.Shell.Framework;
using Sitecore.Web.UI.Sheer;

using Sitecore.Sandbox.Pipelines.DownloadImageToMediaLibrary;

namespace Sitecore.Sandbox.Shell.Applications.ContentEditor
{
    public class Image : Sitecore.Shell.Applications.ContentEditor.Image
    {
        public Image()
            : base()
        {
        }

        public override void HandleMessage(Message message)
        {
            Assert.ArgumentNotNull(message, "message");
            if (string.Equals(message.Name, "contentimage:download", StringComparison.CurrentCultureIgnoreCase))
            {
                GetInputFromUser();
                return;
            }

            base.HandleMessage(message);
        }

        protected void GetInputFromUser()
        {
            RunProcessor("GetImageUrl", new ClientPipelineArgs());
        }

        protected virtual void GetImageUrl(ClientPipelineArgs args)
        {
            if (!args.IsPostBack)
            {
                SheerResponse.Input("Enter the url of the image to download:", string.Empty);
                args.WaitForPostBack();
            }
            else if (args.HasResult && IsValidUrl(args.Result))
            {
                args.Parameters["imageUrl"] = args.Result;
                args.IsPostBack = false;
                RunProcessor("ChooseMediaLibraryFolder", args);
            }
            else
            {
                CancelOperation(args);
            }
        }

        protected virtual bool IsValidUrl(string url)
        {
            if (string.IsNullOrWhiteSpace(url))
            {
                return false;
            }

            try
            {
                HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create(url);
                request.Method = "HEAD";
                request.GetResponse();
            }
            catch (Exception ex)
            {
                SheerResponse.Alert("The specified url is not valid. Please try again.");
                return false;
            }

            return true;
        }

        protected virtual void RunProcessor(string processor, ClientPipelineArgs args)
        {
            Assert.ArgumentNotNullOrEmpty(processor, "processor");
            Sitecore.Context.ClientPage.Start(this, processor, args);
        }

        public void ChooseMediaLibraryFolder(ClientPipelineArgs args)
        {
            if (!args.IsPostBack)
            {
                Dialogs.BrowseItem
                (
                    "Select A Media Library Folder",
                    "Please select a media library folder to store this image.",
                    "Applications/32x32/folder_into.png",
                    "OK",
                    "/sitecore/media library", 
                    string.Empty
                );

                args.WaitForPostBack();
            }
            else if (args.HasResult)
            {
                Item folder = Client.ContentDatabase.Items[args.Result];
                args.Parameters["mediaLibaryFolderPath"] = folder.Paths.FullPath;
                RunProcessor("DownloadImage", args);
            }
            else
            {
                CancelOperation(args);
            }
        }

        protected virtual void DownloadImage(ClientPipelineArgs args)
        {
            DownloadImageToMediaLibraryArgs downloadArgs = new DownloadImageToMediaLibraryArgs
            {
                Database = Client.ContentDatabase,
                ImageUrl = args.Parameters["imageUrl"],
                MediaLibaryFolderPath = args.Parameters["mediaLibaryFolderPath"]
            };

            CorePipeline.Run("downloadImageToMediaLibrary", downloadArgs);
            SetMediaItemInField(downloadArgs);
        }

        protected virtual void SetMediaItemInField(DownloadImageToMediaLibraryArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            if(string.IsNullOrWhiteSpace(args.MediaId) || string.IsNullOrWhiteSpace(args.MediaPath))
            {
                return;
            }

            XmlValue.SetAttribute("mediaid", args.MediaId);
            Value = args.MediaPath;
            Update();
            SetModified();
        }

        protected virtual void CancelOperation(ClientPipelineArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            args.AbortPipeline();
        }
    }
}

The class above subclasses the Sitecore.Shell.Applications.ContentEditor.Image class — this lives in Sitecore.Kernel.dll — which is the “out of the box” Content Editor Image field. The Sitecore.Shell.Applications.ContentEditor.Image class provides hooks that we can override in order to augment functionality which I am doing above.

The magic of this class starts in the HandleMessage method — I intercept the message for a Menu item option that I define below for downloading an image from a URL.

If we are to download an image from a URL, we first prompt the user for a URL via the GetImageUrl method using a Sheer UI api call (note: I am running these methods as one-off client pipeline processors as this is the only way you can get Sheer UI to run properly).

If we have a valid URL, we then prompt the user for a Media Library location via another Sheer UI dialog (this is seen in the ChooseMediaLibraryFolder method).

If the user chooses a location in the Media Library, we then call the DownloadImage method as a client pipeline processor — I had to do this since I was seeing some weird behavior on when the image was being saved into the Media Library — which invokes the custom pipeline for downloading the image to the file system; uploading it into the Media Library; and then removing it from disk.

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

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <controlSources>
      <source mode="on" namespace="Sitecore.Sandbox.Shell.Applications.ContentEditor" assembly="Sitecore.Sandbox" prefix="sandbox-content"/>
    </controlSources>
    <overrideDialogs>
      <override dialogUrl="/sitecore/shell/Applications/Item%20browser.aspx" with="/sitecore/client/applications/dialogs/InsertSitecoreItemViaTreeDialog">
        <patch:delete/>
      </override>
    </overrideDialogs>
    <pipelines>
      <downloadImageToMediaLibrary>
        <processor type="Sitecore.Sandbox.Pipelines.DownloadImageToMediaLibrary.SetProperties, Sitecore.Sandbox">
          <UploadDirectory>/upload</UploadDirectory>
        </processor>  
        <processor type="Sitecore.Sandbox.Pipelines.DownloadImageToMediaLibrary.DownloadImage, Sitecore.Sandbox" />
        <processor type="Sitecore.Sandbox.Pipelines.DownloadImageToMediaLibrary.UploadImageToMediaLibrary, Sitecore.Sandbox">
          <Site>website</Site>
        </processor>
        <processor type="Sitecore.Sandbox.Pipelines.DownloadImageToMediaLibrary.DeleteImageFromFileSystem, Sitecore.Sandbox" />
      </downloadImageToMediaLibrary>
    </pipelines>
  </sitecore>
</configuration>

One thing to note in the above file: I’ve disabled the SPEAK dialog for the Item Browser — you can see this in the <overrideDialogs> xml element — as I wasn’t able to set messaging text on it but could do so using the older Sheer UI dialog.

Now that all code is in place, we need to tell Sitecore that we have a new Image field. I do so by defining it in the Core database:

external-image-core-1

We also need a new Menu item for the “Download Image” link:

external-image-core-2

Let’s take this for a spin!

I added a new field using the custom Image field type to my Sample item template:

template-new-field-external-image

As you can see, we have this new Image field on my “out of the box” home item. Let’s click the “Download Image” link:

home-external-image-1

I was then prompted with a dialog to supply an image URL. I pasted one I found on the internet:

home-external-image-2

After clicking “OK”, I was prompted with another dialog to choose a Media Library location for storing the image. I chose some random folder:

home-external-image-3

After clicking “OK” on that dialog, the image was magically downloaded from the internet; uploaded into the Media Library; and set in the custom Image field on my home item:

home-external-image-4

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

Addendum:
It’s not a good idea to use the /upload directory for temporarily storing download images — see this post for more details.

If you decide to use this solution — by the way, use this solution at your own risk ๐Ÿ˜‰ — you will have to change the /upload directory in the patch include configuration file above.

Add a Custom Attribute to the General Link Field in Sitecore

In my current project, I needed to find a way to give content authors the ability to add a custom attribute — let’s call this custom attribute Tag for simplicity– to the “Insert Link” and “Insert External Link” dialogs of the General Link field (NOTE: the following solution does not use the “out of the box” SPEAK dialogs that ship with Sitecore 7.2 and up. This solution uses the older Sheer UI dialogs. Perhaps I will share a solution in the future on how to do the following using the newer SPEAK dialogs).

You might be asking why? Well, let’s imagine that there is some magical JavaScript code that puts a click event on links, and grabs the value of the tag attribute for reporting purposes — perhaps the JavaScript calls a service that captures this information.

In this post, I am going to share how I went about doing this minus the code I needed to add to get this to work in the Glass.Mapper ORM (I’m going to show you this code in my next blog post).

I first built the following custom LinkField class (this class is not used in this solution but will be used in my next blog post where I should how to integrate the functionality below in Glass.Mapper. I’m just setting the stage ๐Ÿ˜‰ ):

using Sitecore.Data.Fields;

namespace Sitecore.Sandbox.Data.Fields
{
    public class TagLinkField : LinkField
    {
        public TagLinkField(Field innerField)
            : base(innerField)
        {
        }

        public TagLinkField(Field innerField, string runtimeValue)
            : base(innerField, runtimeValue)
        {
        }

        public string Tag
        {
            get
            {
                return GetAttribute("tag");
            }
            set
            {
                this.SetAttribute("tag", value);
            }
        }   
    }
}

The class above subclasses Sitecore.Data.Fields.Link (this lives in Sitecore.Kernel.dll) — this class represents a link in Sitecore — and added a new Tag property (this class will magically parse or save this value into the field’s underlying XML).

Next, I created the following Sheer UI form for a custom “Insert Link” dialog:

using System;
using System.Xml;
using System.Collections.Specialized;

using Sitecore;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Shell.Applications.Dialogs;
using Sitecore.Shell.Applications.Dialogs.InternalLink;
using Sitecore.Web.UI.HtmlControls;
using Sitecore.Web.UI.Sheer;
using Sitecore.Xml;

namespace Sitecore.Sandbox.Shell.Applications.Dialogs.InternalLink
{
    public class TagInternalLinkForm : InternalLinkForm
    {
        private const string TagAttributeName = "tag";

        protected Edit Tag;

        private NameValueCollection customLinkAttributes;
        protected NameValueCollection CustomLinkAttributes 
        { 
            get
            {
                if(customLinkAttributes == null)
                {
                    customLinkAttributes = new NameValueCollection();
                    ParseLinkAttributes(GetLink());
                }

                return customLinkAttributes;
            }
        }

        protected override void OnLoad(EventArgs e)
        {
            Assert.ArgumentNotNull(e, "e");
            base.OnLoad(e);
            if (Context.ClientPage.IsEvent)
            {
                return;
            }
            
            LoadControls();
        }

        protected override void ParseLink(string link)
        {
            base.ParseLink(link);
            ParseLinkAttributes(link);
        }

        protected virtual void ParseLinkAttributes(string link)
        {
            Assert.ArgumentNotNull(link, "link");
            XmlDocument xmlDocument = XmlUtil.LoadXml(link);
            if (xmlDocument == null)
            {
                return;
            }

            XmlNode node = xmlDocument.SelectSingleNode("/link");
            if (node == null)
            {
                return;
            }

            CustomLinkAttributes[TagAttributeName] = XmlUtil.GetAttribute(TagAttributeName, node);
        }

        protected virtual void LoadControls()
        {
            string tagValue = CustomLinkAttributes[TagAttributeName];
            if (!string.IsNullOrWhiteSpace(tagValue))
            {
                Tag.Value = tagValue;
            }
        }

        protected override void OnOK(object sender, EventArgs args)
        {
            Assert.ArgumentNotNull(sender, "sender");
            Assert.ArgumentNotNull(args, "args");
            Item selectionItem = Treeview.GetSelectionItem();
            if (selectionItem == null)
            {
                Context.ClientPage.ClientResponse.Alert("Select an item.");
            }
            else
            {
                string attributeFromValue = LinkForm.GetLinkTargetAttributeFromValue(this.Target.Value, this.CustomTarget.Value);
                string queryString = this.Querystring.Value;
                if (queryString.StartsWith("?", StringComparison.InvariantCulture))
                    queryString = queryString.Substring(1);

                Packet packet = new Packet("link", new string[0]);
                LinkForm.SetAttribute(packet, "text", (Control)Text);
                LinkForm.SetAttribute(packet, "linktype", "internal");
                LinkForm.SetAttribute(packet, "anchor", (Control)Anchor);
                LinkForm.SetAttribute(packet, "title", (Control)Title);
                LinkForm.SetAttribute(packet, "class", (Control)Class);
                LinkForm.SetAttribute(packet, "querystring", queryString);
                LinkForm.SetAttribute(packet, "target", attributeFromValue);
                LinkForm.SetAttribute(packet, "id", selectionItem.ID.ToString());

                TrimEditControl(Tag);
                LinkForm.SetAttribute(packet, TagAttributeName, (Control)Tag);

                Assert.IsTrue(!string.IsNullOrEmpty(selectionItem.ID.ToString()) && ID.IsID(selectionItem.ID.ToString()), "ID doesn't exist.");
                SheerResponse.SetDialogValue(packet.OuterXml);
                SheerResponse.CloseWindow();
            }
        }

        protected virtual void TrimEditControl(Edit control)
        {
            if(control == null || string.IsNullOrEmpty(control.Value))
            {
                return;
            }

            control.Value = control.Value.Trim();
        }
    }
}

The OnLoad method invokes its base class’ OnLoad method — the base class’ OnLoad method loads values from the field’s XML into the Edit controls on the form — and also parses the value from the tag XML attribute and places it into the Tag Edit control.

The ParseLink method above is where values from the field’s XML are extracted — these are extracted from the XML attributes of the field. The ParseLink method delegates to the ParseLinkAttributes method which extracts the value from the tag attribute.

The OnOK method is where values from the Edit controls are extract and passed to a class instance that generates XML for the field. I could not call the base class’ OnOK method since it would prevent me from saving the custom tag attribute and value, so I “borrowed/stole” code from it, and then added my modifications.

I then added new Tag Literal and Edit controls to the “Internal Link” dialog, and also updated the CodeBeside xml element to point to my new class (I copy and pasted this from /sitecore/shell/Applications/Dialogs/InsertLink.InsertLink.xml and put my new file into /sitecore/shell/Override/InternalLink/InsertLink.xml in my website root — always put custom Sheer UI dialogs XML files in /sitecore/shell/Override/ so that you don’t run into issues when upgrading Sitecore):

<?xml version="1.0" encoding="utf-8" ?>
<control xmlns:def="Definition" xmlns="http://schemas.sitecore.net/Visual-Studio-Intellisense">
  <InternalLink>
    <FormDialog Icon="Network/32x32/link.png" Header="Internal Link" Text="Select the item that you want to create a link to and specify the appropriate properties." OKButton="OK">
      <Stylesheet Key="Style">
        .ff input { 
          width: 160px;
        }        
      </Stylesheet>
      <CodeBeside Type="Sitecore.Sandbox.Shell.Applications.Dialogs.InternalLink.TagInternalLinkForm, Sitecore.Sandbox" />

      <DataContext ID="InternalLinkDataContext"/>
     
      <GridPanel Columns="2" Width="100%" Height="100%" CellPadding="4" Style="table-layout:fixed">
        <Scrollbox Width="100%" Height="100%" Class="scScrollbox scFixSize" Background="window" Padding="0" Border="1px solid #CFCFCF" GridPanel.VAlign="top" GridPanel.Width="100%" GridPanel.Height="100%">
          <TreeviewEx ID="Treeview" DataContext="InternalLinkDataContext" MultiSelect="False" Width="100%"/>
        </Scrollbox>
      
        <Scrollbox Width="256" Height="100%" Background="transparent" Border="none" GridPanel.VAlign="top" GridPanel.Width="256">
          <GridPanel CellPadding="2" Columns="2">
            <Literal Text="Link Description:" GridPanel.NoWrap="true"/>
            <Edit ID="Text"/>
            
            <Literal Text="Anchor:" GridPanel.NoWrap="true"/>
            <Edit ID="Anchor"/>

            <Label for="Target" GridPanel.NoWrap="true"><Literal Text="Target Window:"/></Label>
            <Combobox ID="Target" Width="100%" Change="OnListboxChanged">
              <ListItem Value="Self" Header="Active browser"/>
              <ListItem Value="New" Header="New browser"/>
              <ListItem Value="Custom" Header="Custom"/>
            </Combobox>
            
            <Panel ID="CustomLabel" Background="transparent" Border="none" GridPanel.NoWrap="true" GridPanel.Align="right"><Label For="CustomTarget"><Literal Text="Custom:" /></Label></Panel>
            <Edit ID="CustomTarget" />

            <Literal Text="Style Class:" GridPanel.NoWrap="true"/>
            <Edit ID="Class"/>
            
            <Literal Text="Alternate Text:" GridPanel.NoWrap="true"/>
            <Edit ID="Title"/>

            <Literal Text="Query String:" GridPanel.NoWrap="true"/>
            <Edit ID="Querystring"/>
          
            <Literal Text="Tag:" GridPanel.NoWrap="true"/>
            <Edit ID="Tag"/>
          </GridPanel>
        </Scrollbox>
      
      </GridPanel>
      
    </FormDialog>            
  </InternalLink>
</control>

Likewise, I repeated the steps for the “External Link” dialog’s code-beside class (I’m not going to go into details here since they are the same as the “Insert Link” dialog class above):

using System;

using Sitecore;
using Sitecore.Diagnostics;
using Sitecore.Shell.Applications.Dialogs;
using Sitecore.Shell.Applications.Dialogs.ExternalLink;
using Sitecore.Web.UI.HtmlControls;
using Sitecore.Web.UI.Sheer;
using Sitecore.Xml;
using System.Collections.Specialized;
using System.Xml;

namespace Sitecore.Sandbox.Shell.Applications.Dialogs.ExternalLink
{
    public class TagExternalLinkForm : ExternalLinkForm
    {
        private const string TagAttributeName = "tag";

        protected Edit Tag;

        private NameValueCollection customLinkAttributes;
        protected NameValueCollection CustomLinkAttributes
        {
            get
            {
                if (customLinkAttributes == null)
                {
                    customLinkAttributes = new NameValueCollection();
                    ParseLinkAttributes(GetLink());
                }

                return customLinkAttributes;
            }
        }

        protected override void ParseLink(string link)
        {
            base.ParseLink(link);
            ParseLinkAttributes(link);
        }

        protected virtual void ParseLinkAttributes(string link)
        {
            Assert.ArgumentNotNull(link, "link");
            XmlDocument xmlDocument = XmlUtil.LoadXml(link);
            if (xmlDocument == null)
            {
                return;
            }

            XmlNode node = xmlDocument.SelectSingleNode("/link");
            if (node == null)
            {
                return;
            }

            CustomLinkAttributes[TagAttributeName] = XmlUtil.GetAttribute(TagAttributeName, node);
        }

        protected override void OnLoad(EventArgs e)
        {
            Assert.ArgumentNotNull(e, "e");
            base.OnLoad(e);
            if (Context.ClientPage.IsEvent)
            {
                return;
            }

            LoadControls();
        }

        protected virtual void LoadControls()
        {
            string tagValue = CustomLinkAttributes[TagAttributeName];
            if (!string.IsNullOrWhiteSpace(tagValue))
            {
                Tag.Value = tagValue;
            }
        }
        
        protected override void OnOK(object sender, EventArgs args)
        {
            Assert.ArgumentNotNull(sender, "sender");
            Assert.ArgumentNotNull(args, "args");
            string path = GetPath();
            string attributeFromValue = LinkForm.GetLinkTargetAttributeFromValue(Target.Value, CustomTarget.Value);
            Packet packet = new Packet("link", new string[0]);
            LinkForm.SetAttribute(packet, "text", (Control)Text);
            LinkForm.SetAttribute(packet, "linktype", "external");
            LinkForm.SetAttribute(packet, "url", path);
            LinkForm.SetAttribute(packet, "anchor", string.Empty);
            LinkForm.SetAttribute(packet, "title", (Control)Title);
            LinkForm.SetAttribute(packet, "class", (Control)Class);
            LinkForm.SetAttribute(packet, "target", attributeFromValue);

            TrimEditControl(Tag);
            LinkForm.SetAttribute(packet, TagAttributeName, (Control)Tag);

            SheerResponse.SetDialogValue(packet.OuterXml);
            SheerResponse.CloseWindow();
        }

        private string GetPath()
        {
            string url = this.Url.Value;
            if (url.Length > 0 && url.IndexOf("://", StringComparison.InvariantCulture) < 0 && !url.StartsWith("/", StringComparison.InvariantCulture))
            {
                url = string.Concat("http://", url);
            }
                
            return url;
        }

        protected virtual void TrimEditControl(Edit control)
        {
            if (control == null || string.IsNullOrEmpty(control.Value))
            {
                return;
            }

            control.Value = control.Value.Trim();
        }
    }
}

I also added a Label and Edit control for the Tag as I did for the “Insert Link” dialog above (the “out of the box” External Link dialog xml file lives in /sitecore/shell/Applications/Dialogs/ExternalLink/ExternalLink.xml of the Sitecore website root. When creating custom one be sure to put it in /sitecore/shell/override of your website root):

<?xml version="1.0" encoding="utf-8" ?>
<control xmlns:def="Definition" xmlns="http://schemas.sitecore.net/Visual-Studio-Intellisense">
  <ExternalLink>
    <FormDialog Header="Insert External Link" Text="Enter the URL for the external website that you want to insert a link to and specify any additional properties for the link." OKButton="Insert">

      <CodeBeside Type="Sitecore.Sandbox.Shell.Applications.Dialogs.ExternalLink.TagExternalLinkForm, Sitecore.Sandbox"/>

      <GridPanel Class="scFormTable" CellPadding="2" Columns="2" Width="100%">
        <Label For="Text" GridPanel.NoWrap="true">
          <Literal Text="Link description:"/>
        </Label>
        <Edit ID="Text" Width="100%" GridPanel.Width="100%"/>

        <Label For="Url" GridPanel.NoWrap="true">
          <Literal Text="URL:"/>
        </Label>
        <Border>
          <GridPanel Columns="2" Width="100%">
            <Edit ID="Url" Width="100%" GridPanel.Width="100%" />
            <Button id="Test" Header="Test" Style="margin-left: 10px;" Click="OnTest"/>
          </GridPanel>
        </Border>

        <Label for="Target" GridPanel.NoWrap="true">
          <Literal Text="Target window:"/>
        </Label>
        <Combobox ID="Target" GridPanel.Width="100%" Width="100%" Change="OnListboxChanged">
          <ListItem Value="Self" Header="Active browser"/>
          <ListItem Value="New" Header="New browser"/>
          <ListItem Value="Custom" Header="Custom"/>
        </Combobox>

        <Panel ID="CustomLabel" Disabled="true" Background="transparent" Border="none" GridPanel.NoWrap="true">
          <Label For="CustomTarget">
            <Literal Text="Custom:" />
          </Label>
        </Panel>
        <Edit ID="CustomTarget" Width="100%" Disabled="true"/>

        <Label For="Class" GridPanel.NoWrap="true">
          <Literal Text="Style class:" />
        </Label>
        <Edit ID="Class" Width="100%" />

        <Label for="Title" GridPanel.NoWrap="true">
          <Literal Text="Alternate text:"/>
        </Label>
        <Edit ID="Title" Width="100%" />
        
        <Label for="Tag" GridPanel.NoWrap="true">
          <Literal Text="Tag:"/>
        </Label>
        <Edit ID="Tag" Width="100%" />
      </GridPanel>

    </FormDialog>
  </ExternalLink>
</control>

Since the “out of the box” “External Link” dialog isn’t tall enough for the new Tag Label and Edit controls — I had no quick way of changing this since the height of the dialog is hard-coded in Sitecore.Shell.Applications.ContentEditor.Link in Sitecore.Client — I decided to create a new Content Editor field for the General Link field — this is further down in this post — which grabs the Url of the dialog and dimensions from a custom pipeline I built (the dimensions live in the patch configuration file that is found later on in this post). This custom pipeline uses the following PipelineArgs class:

using Sitecore.Collections;
using Sitecore.Pipelines;

namespace Sitecore.Sandbox.Pipelines.DialogInfo
{
    public class DialogInfoArgs : PipelineArgs
    {
        public string Message { get; set; }

        public string Url { get; set; }

        public SafeDictionary<string, string> Parameters { get; set; }

        public DialogInfoArgs()
        {
            Parameters = new SafeDictionary<string, string>();
        }

        public bool HasInformation()
        {
            return !string.IsNullOrWhiteSpace(Url);
        }
    }
}

Each dialog defined in a pipeline processor of this custom pipeline will specify the dialog’s Url; it’s message — this is how the code ascertains which dialog to load; and any properties of the dialog (e.g. height).

I then built the following class that serves as a processor for this custom pipeline:

using System;
using System.Xml;

using Sitecore.Collections;
using Sitecore.Diagnostics;

namespace Sitecore.Sandbox.Pipelines.DialogInfo
{
    public class SetDialogInfo
    {
        protected virtual string ParameterNameAttributeName { get; private set; }

        protected virtual string ParameterValueAttributeName { get; private set; }

        protected virtual string Message { get; private set; }

        protected virtual string Url { get; private set; }

        protected virtual SafeDictionary<string, string> Parameters { get; private set; }

        public SetDialogInfo()
        {
            Parameters = new SafeDictionary<string, string>();
        }

        public void Process(DialogInfoArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
            if(!CanProcess(args))
            {
                return;
            }

            SetDialogInformation(args);
        }

        protected virtual bool CanProcess(DialogInfoArgs args)
        {
            return !string.IsNullOrWhiteSpace(Message)
                && !string.IsNullOrWhiteSpace(Url)
                && args != null
                && !string.IsNullOrWhiteSpace(args.Message)
                && string.Equals(args.Message, Message, StringComparison.CurrentCultureIgnoreCase);
        }
        
        protected virtual void SetDialogInformation(DialogInfoArgs args)
        {
            args.Url = Url;
            args.Parameters = Parameters;
        }

        protected virtual void AddParameter(XmlNode node)
        {
            Assert.ArgumentNotNullOrEmpty(ParameterNameAttributeName, "ParameterNameAttributeName");
            Assert.ArgumentNotNullOrEmpty(ParameterValueAttributeName, "ParameterValueAttributeName");
            if (node == null || !IsAttributeSet(node.Attributes[ParameterNameAttributeName]) || !IsAttributeSet(node.Attributes[ParameterValueAttributeName]))
            {
                return;
            }

            Parameters[node.Attributes[ParameterNameAttributeName].Value] = node.Attributes[ParameterValueAttributeName].Value;
        }

        protected bool IsAttributeSet(XmlAttribute attribute)
        {
            return attribute != null && !string.IsNullOrEmpty(attribute.Value);
        }
    }
}

The Sitecore Configuration Factory injects the dialog’s url, message and parameters into the class instance.

The CanProcess method determines if there is match with the message that is sent via the DialogInfoArgs instance passed to the processor’s Process method. If there is a match, the Url and dialog parameters are set on the DialogInfoArgs instance.

If there isn’t a match, the processor just exits and does nothing.

I then built the following class to serve as a custom Sitecore.Shell.Applications.ContentEditor.Link:

using System;
using System.Collections.Specialized;

using Sitecore.Collections;
using Sitecore.Diagnostics;
using Sitecore.Pipelines;
using Sitecore.Shell.Applications.ContentEditor;
using Sitecore.Web.UI.Sheer;

using Sitecore.Sandbox.Pipelines.DialogInfo;

namespace Sitecore.Sandbox.Shell.Applications.ContentEditor
{
    public class TagLink : Link
    {
        public override void HandleMessage(Message message)
        {
            Assert.ArgumentNotNull(message, "message");
            if (message["id"] != ID)
            {
                return;
            }

            DialogInfoArgs info = GetDialogInformation(message.Name);
            if (info.HasInformation())
            {
                Insert(info.Url, ToNameValueCollection(info.Parameters));
                return;
            }

            base.HandleMessage(message);
        }

        protected virtual DialogInfoArgs GetDialogInformation(string message)
        {
            Assert.ArgumentNotNullOrEmpty(message, "message");
            DialogInfoArgs args = new DialogInfoArgs { Message = message };
            CorePipeline.Run("dialogInfo", args);
            return args;
        }

        protected virtual NameValueCollection ToNameValueCollection(SafeDictionary<string, string> dictionary)
        {
            if(dictionary == null)
            {
                return new NameValueCollection();
            }

            NameValueCollection collection = new NameValueCollection();
            foreach(string key in dictionary.Keys)
            {
                collection.Add(key, dictionary[key]);
            }

            return collection;
        }
    }
}

The HandleMessage method above passes the message name to the custom <dialogInfo> pipeline and gets back a DialogInfoArgs instance with the dialog’s Url and parameters if there is a match. If there is no match, then the HandleMessage method delegates to its base class’ HandleMessage method (there are dialog Urls and Parameters baked in it).

Now we need to let Sitecore know about the above Content Editor class. We do so like this:

point-to-custom-general-link-in-core

Now that the Content Editor bits are in place, we need some code to render the tag on the front-end of the website. I do this in the following class which serves as a custom <renderField> pipeline processor:

using System.Xml;

using Sitecore.Pipelines.RenderField;
using Sitecore.Xml;

namespace Sitecore.Sandbox.Pipelines.RenderField
{
    public class SetTagAttributeOnLink
    {
        private string TagXmlAttributeName { get; set; }

        private string TagAttributeName { get; set; }
        
        private string BeginningHtml { get; set; }

        public void Process(RenderFieldArgs args)
        {
            if (!CanProcess(args))
            {
                return;
            }

            args.Result.FirstPart = AddTagAttributeValue(args.Result.FirstPart, TagAttributeName, GetXmlAttributeValue(args.FieldValue, TagXmlAttributeName));
        }

        protected virtual bool CanProcess(RenderFieldArgs args)
        {
            return !string.IsNullOrWhiteSpace(TagAttributeName)
                    && !string.IsNullOrWhiteSpace(BeginningHtml)
                    && !string.IsNullOrWhiteSpace(TagXmlAttributeName)
                    && args != null
                    && args.Result != null
                    && HasXmlAttributeValue(args.FieldValue, TagAttributeName)
                    && !string.IsNullOrWhiteSpace(args.Result.FirstPart)
                    && args.Result.FirstPart.ToLower().StartsWith(BeginningHtml.ToLower());
        }

        protected virtual bool HasXmlAttributeValue(string linkXml, string attributeName)
        {
            return !string.IsNullOrWhiteSpace(GetXmlAttributeValue(linkXml, attributeName));
        }

        protected virtual string GetXmlAttributeValue(string linkXml, string attributeName)
        {
            XmlDocument xmlDocument = XmlUtil.LoadXml(linkXml);
            if(xmlDocument == null)
            {
                return string.Empty;
            }

            XmlNode node = xmlDocument.SelectSingleNode("/link");
            if (node == null)
            {
                return string.Empty;
            }

            return XmlUtil.GetAttribute(TagAttributeName, node);
        }

        protected virtual string AddTagAttributeValue(string html, string attributeName, string attributeValue)
        {
            if(string.IsNullOrWhiteSpace(html) || string.IsNullOrWhiteSpace(attributeName) || string.IsNullOrWhiteSpace(attributeValue))
            {
                return html;
            }

            int index = html.LastIndexOf(">");
            if (index < 0)
            {
                return html;
            }

            string firstPart = html.Substring(0, index);
            string attribute = string.Format(" {0}=\"{1}\"", attributeName, attributeValue);
            string lastPart = html.Substring(index);
            return string.Concat(firstPart, attribute, lastPart);
        }
    }
}

The Process method above delegates to the CanProcess method which determines if the generated HTML by the previous <renderField> pipeline processors should be manipulated — the code should only run it the generated HTML is a link and only when there is a tag attribute set on the field.

If the HTML should be manipulated, we basically add the tag attribute with its value it to the generated link HTML — this is done in the AddTagAttributeValue method.

I then wired everything together via the following patch configuration file:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <controlSources>
      <source mode="on" namespace="Sitecore.Sandbox.Shell.Applications.ContentEditor" assembly="Sitecore.Sandbox" prefix="sandbox-content"/>
    </controlSources>
    <fieldTypes>
      <fieldType name="General Link">
        <patch:attribute name="type">Sitecore.Sandbox.Data.Fields.TagLinkField, Sitecore.Sandbox</patch:attribute>
      </fieldType>
      <fieldType name="General Link with Search">
        <patch:attribute name="type">Sitecore.Sandbox.Data.Fields.TagLinkField, Sitecore.Sandbox</patch:attribute>
      </fieldType>
      <fieldType name="link">
        <patch:attribute name="type">Sitecore.Sandbox.Data.Fields.TagLinkField, Sitecore.Sandbox</patch:attribute>
        </fieldType>
    </fieldTypes>
    <pipelines>
      <dialogInfo>
        <processor type="Sitecore.Sandbox.Pipelines.DialogInfo.SetDialogInfo, Sitecore.Sandbox">
          <ParameterNameAttributeName>name</ParameterNameAttributeName>
          <ParameterValueAttributeName>value</ParameterValueAttributeName>
          <Message>contentlink:externallink</Message>
          <Url>/sitecore/shell/Applications/Dialogs/External link.aspx</Url>
          <parameters hint="raw:AddParameter">
            <parameter name="height" value="300" />
          </parameters>
        </processor>
      </dialogInfo>
      <renderField>
        <processor patch:after="processor[@type='Sitecore.Pipelines.RenderField.GetInternalLinkFieldValue, Sitecore.Kernel']" 
                   type="Sitecore.Sandbox.Pipelines.RenderField.SetTagAttributeOnLink, Sitecore.Sandbox">
          <TagXmlAttributeName>tag</TagXmlAttributeName>
          <TagAttributeName>tag</TagAttributeName>
          <BeginningHtml>&lt;a </BeginningHtml>
        </processor>  
      </renderField>
    </pipelines>
  </sitecore>
</configuration>

Let’s try this out!

For testing I added two General Link fields to the Sample Item template (/sitecore/templates/Sample/Sample Item in the master database):

added-two-general-link-fields

I also had to add two Link field controls to the sample rendering.xslt that ships with Sitecore:

added-two-link-field-controls

Let’s test the “Insert Link” dialog:

set-tag-value-insert-link

After clicking the “OK” button and saving the Item, I looked at the “Raw values” on the field and saw that the tag was added to the field’s xml:

tag-value-set-insert-link

Let’s see if this works on the “Insert External Link” dialog:

set-tag-value-insert-external-link

After clicking the “OK” button and saving the Item, I looked at the “Raw values” on the field and saw that the tag was added to the field’s xml:

tag-value-set-insert-external-link

After publishing everything, I navigated to my home page and looked at its rendered HTML. As you can see, the tag attributes were added to the links:

links-front-end-rendered

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

Until next time, keep on Sitecoring!