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:
Yucky, right?
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:
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.
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:
Let’s insert an internal link to this Item:
Let’s insert another link to this Item:
Let’s insert yet another internal link — let’s insert a link to this Item:
After publishing and navigating to my home page, I see this in the html for the links:
After clicking on the first link, I’m brought to the bucketed Item but have a look at the URL:
As you can see it worked!
When I finally got this working, I found myself doing a happy dance:

If you have any thoughts on this, please drop a comment.
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:
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:
By default, the Bucket Folder Items are named based on a DateTime format which is housed in a configuration setting in Sitecore:
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:
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:
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:
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:
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:
Let’s turn it into an Item Bucket:
As you can see, it has Bucket Folders with random integers as their Item names:
Let’s now try this on an Item that has no presentation but only 4 child items:
Let’s convert this Item into an Item Bucket:
As you can see, the default Item Bucket folder structure was created:
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:
I first performed a copy and paste of the Razor file into a new file:
I then deleted the new Razor file:
Next, I renamed the Razor file:
When I looked in my Sitecore log file, I saw that all operations were executed:
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:
Let’s move an image into the /upload folder of my Sitecore instance:
After reloading the “uploaded” Media Library folder, I see that the image was uploaded to it:
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:
I then copied an image from a folder on my Desktop to the /upload directory of my Sitecore instance:
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:
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:
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’]:
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:
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:
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.
Download Random Giphy Images and Save to the Media Library Via a Custom Content Editor Image Field in Sitecore
In my previous post I created a custom Content Editor image field in the Sitecore Experience Platform. This custom image field gives content authors the ability to download an image from outside of their Sitecore instance; save the image to the Media Library; and then map that resulting Media Library Item to the custom Image field on an Item in the content tree.
Building that solution was a great way to spend a Friday night (and even the following Saturday morning) — though I bet some people would argue watching cat videos on YouTube might be better way to spend a Friday night — and even gave me the opportunity to share that solution with you guys.
After sharing this post on Twitter, Sitecore MVP Kam Figy replied to that tweet with the following:
This gave me an idea: why not modify the solution from my previous post to give the ability to download a random image from Giphy via their the API?
You might be asking yourself “what is this Giphy thing?” Giphy is basically a site that allows users to upload images — more specifically animated GIFs — and then associate those uploaded images with tags. These tags are used for finding images on their site and also through their API.
You might be now asking “what’s the point of Giphy?” The point is to have fun and share a laugh; animated GIFs can be a great way of achieving these.
Some smart folks out there have built integrations into other software platforms which give users the ability pull images from the Giphy API. An example of this can be seen in Slack messaging application.
As a side note, if you aren’t on the Sitecore Community Slack, you probably should be. This is the fastest way to get help, share ideas and even have some good laughs from close to 1000 Sitecore developers, architects and marketers from around the world in real-time. If you would like to join the Sitecore Community Slack, please let me know and I will send you an invite though please don’t ask for an invite in comments section below on this post. Instead reach out to me on Twitter: @mike_i_reynolds. You can also reach out to Sitecore MVP Akshay Sura: @akshaysura13.
Here’s an example of me calling up an image using some tags in one of the channels on the Sitecore Community Slack using the Giphy integration for Slack:
There really isn’t anything magical about the Giphy API — all you have to do is send an HTTP request with some query string parameters. Giphy’s API will then give you a response in JSON:
Before I dig into the solution below, I do want to let you know I will not be talking about all of the code in the solution. Most of the code was repurposed from my previous post. If you have not read my previous post, please read it before moving forward so you have a full understanding of how this works.
Moreover, do note there is probably no business value in using the following solution as is — it was built for fun on another Friday night and Saturday morning. 😉
To get data out of this JSON response, I decided to use Newtonsoft.Json. Why did I choose this? It was an easy decision: Newtonsoft.Json comes with Sitecore “out of the box” so it was convenient for me to choose this as a way to parse the JSON coming from the Giphy API.
I created the following model classes with JSON to C# property mappings:
using Newtonsoft.Json; namespace Sitecore.Sandbox.Providers { public class GiphyData { [JsonProperty("type")] public string Type { get; set; } [JsonProperty("id")] public string Id { get; set; } [JsonProperty("url")] public string Url { get; set; } [JsonProperty("image_original_url")] public string ImageOriginalUrl { get; set; } [JsonProperty("image_url")] public string ImageUrl { get; set; } [JsonProperty("image_mp4_url")] public string ImageMp4Url { get; set; } [JsonProperty("image_frames")] public string ImageFrames { get; set; } [JsonProperty("image_width")] public string ImageWidth { get; set; } [JsonProperty("image_height")] public string ImageHeight { get; set; } [JsonProperty("fixed_height_downsampled_url")] public string FixedHeightDownsampledUrl { get; set; } [JsonProperty("fixed_height_downsampled_width")] public string FixedHeightDownsampledWidth { get; set; } [JsonProperty("fixed_height_downsampled_height")] public string FixedHeightDownsampledHeight { get; set; } [JsonProperty("fixed_width_downsampled_url")] public string FixedWidthDownsampledUrl { get; set; } [JsonProperty("fixed_width_downsampled_width")] public string FixedWidthDownsampledWidth { get; set; } [JsonProperty("fixed_width_downsampled_height")] public string FixedWidthDownsampledHeight { get; set; } [JsonProperty("fixed_height_small_url")] public string FixedHeightSmallUrl { get; set; } [JsonProperty("fixed_height_small_still_url")] public string FixedHeightSmallStillUrl { get; set; } [JsonProperty("fixed_height_small_width")] public string FixedHeightSmallWidth { get; set; } [JsonProperty("fixed_height_small_height")] public string FixedHeightSmallHeight { get; set; } [JsonProperty("fixed_width_small_url")] public string FixedWidthSmallUrl { get; set; } [JsonProperty("fixed_width_small_still_url")] public string FixedWidthSmallStillUrl { get; set; } [JsonProperty("fixed_width_small_width")] public string FixedWidthSmallWidth { get; set; } [JsonProperty("fixed_width_small_height")] public string FixedWidthSmallHeight { get; set; } [JsonProperty("username")] public string Username { get; set; } [JsonProperty("caption")] public string Caption { get; set; } } }
using Newtonsoft.Json; namespace Sitecore.Sandbox.Providers { public class GiphyMeta { [JsonProperty("status")] public int Status { get; set; } [JsonProperty("msg")] public string Message { get; set; } } }
using Newtonsoft.Json; namespace Sitecore.Sandbox.Providers { public class GiphyResponse { [JsonProperty("data")] public GiphyData Data { get; set; } [JsonProperty("meta")] public GiphyMeta Meta { get; set; } } }
Every property above in every class represents a JSON property/object in the response coming back from the Giphy API.
Now, we need a way to make a request to the Giphy API. I built the following interface whose instances will do just that:
namespace Sitecore.Sandbox.Providers { public interface IGiphyImageProvider { GiphyData GetRandomGigphyImageData(string tags); } }
The following class implements the interface above:
using System; using System.Net; using Sitecore.Diagnostics; using Newtonsoft.Json; using System.IO; namespace Sitecore.Sandbox.Providers { public class GiphyImageProvider : IGiphyImageProvider { private string RequestUrlFormat { get; set; } private string ApiKey { get; set; } public GiphyData GetRandomGigphyImageData(string tags) { Assert.IsNotNullOrEmpty(RequestUrlFormat, "RequestUrlFormat"); Assert.IsNotNullOrEmpty(ApiKey, "ApiKey"); Assert.ArgumentNotNullOrEmpty(tags, "tags"); string response = GetJsonResponse(GetRequestUrl(tags)); if(string.IsNullOrWhiteSpace(response)) { return new GiphyData(); } try { GiphyResponse giphyResponse = JsonConvert.DeserializeObject<GiphyResponse>(response); if(giphyResponse != null && giphyResponse.Meta != null && giphyResponse.Meta.Status == 200 && giphyResponse.Data != null) { return giphyResponse.Data; } } catch(Exception ex) { Log.Error(ToString(), ex, this); } return new GiphyData(); } protected virtual string GetRequestUrl(string tags) { Assert.ArgumentNotNullOrEmpty(tags, "tags"); return string.Format(RequestUrlFormat, ApiKey, Uri.EscapeDataString(tags)); } protected virtual string GetJsonResponse(string requestUrl) { Assert.ArgumentNotNullOrEmpty(requestUrl, "requestUrl"); try { WebRequest request = HttpWebRequest.Create(requestUrl); request.Method = "GET"; string json; using (WebResponse response = request.GetResponse()) { using (Stream responseStream = response.GetResponseStream()) { using (StreamReader sr = new StreamReader(responseStream)) { return sr.ReadToEnd(); } } } } catch (Exception ex) { Log.Error(ToString(), ex, this); } return string.Empty; } } }
Code in the methods above basically take in tags for the type of random image we want from Giphy; build up the request URL — the template of the request URL and API key (I’m using the public key which is open for developers to experiment with) are populated via the Sitecore Configuration Factory (have a look at the patch include configuration file further down in this post to get an idea of how the properties of this class are populated); make the request to the Giphy API; get back the response; hand the response over to some Newtonsoft.Json API code to parse JSON into model instances of the classes shown further above in this post; and then return the nested model instances.
I then created the following Sitecore.Shell.Applications.ContentEditor.Image subclass which represents the custom Content Editor Image field:
using System; using Sitecore.Configuration; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Pipelines; using Sitecore.Shell.Framework; using Sitecore.Web.UI.Sheer; using Sitecore.Sandbox.Pipelines.DownloadImageToMediaLibrary; using Sitecore.Sandbox.Providers; namespace Sitecore.Sandbox.Shell.Applications.ContentEditor { public class GiphyImage : Sitecore.Shell.Applications.ContentEditor.Image { private IGiphyImageProvider GiphyImageProvider { get; set; } public GiphyImage() : base() { GiphyImageProvider = GetGiphyImageProvider(); } protected virtual IGiphyImageProvider GetGiphyImageProvider() { IGiphyImageProvider giphyImageProvider = Factory.CreateObject("imageProviders/giphyImageProvider", false) as IGiphyImageProvider; Assert.IsNotNull(giphyImageProvider, "The giphyImageProvider was not properly defined in configuration"); return giphyImageProvider; } public override void HandleMessage(Message message) { Assert.ArgumentNotNull(message, "message"); if (string.Equals(message.Name, "contentimage:downloadGiphy", StringComparison.CurrentCultureIgnoreCase)) { GetInputFromUser(); return; } base.HandleMessage(message); } protected void GetInputFromUser() { RunProcessor("GetGiphyTags", new ClientPipelineArgs()); } protected virtual void GetGiphyTags(ClientPipelineArgs args) { if (!args.IsPostBack) { SheerResponse.Input("Enter giphy tags:", string.Empty); args.WaitForPostBack(); } else if (args.HasResult) { args.Parameters["tags"] = args.Result; args.IsPostBack = false; RunProcessor("GetGiphyImageUrl", args); } else { CancelOperation(args); } } protected virtual void GetGiphyImageUrl(ClientPipelineArgs args) { GiphyData giphyData = GiphyImageProvider.GetRandomGigphyImageData(args.Parameters["tags"]); if (giphyData == null || string.IsNullOrWhiteSpace(giphyData.ImageUrl)) { SheerResponse.Alert("Unfortunately, no image matched the tags you specified. Please try again."); CancelOperation(args); return; } args.Parameters["imageUrl"] = giphyData.ImageUrl; args.IsPostBack = false; RunProcessor("ChooseMediaLibraryFolder", args); } 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 the Giphy 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; args.IsPostBack = false; 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 does not differ much from the Image class I shared in my previous post. The only differences are in the instantiation of an IGiphyImageProvider object using the Sitecore Configuration Factory — this object is used for getting the Giphy image URL from the Giphy API; the GetGiphyTags() method prompts the user for tags used in calling up a random image from Giphy; and in the GetGiphyImageUrl() method which uses the IGiphyImageProvider instance to get the image URL. The rest of the code in this class is unmodified from the Image class shared in my previous post.
I then defined the IGiphyImageProvider code in the following patch include configuration file:
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <imageProviders> <giphyImageProvider type="Sitecore.Sandbox.Providers.GiphyImageProvider, Sitecore.Sandbox" singleInstance="true"> <RequestUrlFormat>http://api.giphy.com/v1/gifs/random?api_key={0}&tag={1}</RequestUrlFormat> <ApiKey>dc6zaTOxFJmzC</ApiKey> </giphyImageProvider> </imageProviders> </sitecore> </configuration>
Be sure to check out the patch include configuration file from my previous post as it contains the custom pipeline that downloads images from a URL.
You should also refer my previous post which shows you how to register a custom Content Editor field in the core database of Sitecore.
Let’s test this out.
We need to add this new field to a template. I’ve added it to the “out of the box” Sample Item template:
My Home item uses the above template. Let’s download a random Giphy image on it:
I then supplied some tags for getting a random image:
Let’s choose a place to save the image in the Media Library:
As you can see, the image was downloaded and saved into the Media Library in the selected folder, and then saved in the custom field on the Home item:
If you are curious, this is the image that was returned by the Giphy API:
If you have any thoughts on this, please share in a comment.
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:
We also need a new Menu item for the “Download Image” link:
Let’s take this for a spin!
I added a new field using the custom Image field type to my Sample item template:
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:
I was then prompted with a dialog to supply an image URL. I pasted one I found on the internet:
After clicking “OK”, I was prompted with another dialog to choose a Media Library location for storing the image. I chose some random folder:
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:
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.