Delete Associated Files on the Filesystem of Sitecore Items Deleted From the Recycle Bin
Last week a question was asked in one of the SDN forums on how one should go about deleting files on the filesystem that are associated with Items that are permanently deleted from the Recycle Bin — I wasn’t quite clear on what the original poster meant by files being linked to Items inside of Sitecore, but I assumed this relationship would be defined somewhere, or somehow.
After doing some research, I reckoned one could create a new command based on Sitecore.Shell.Framework.Commands.Archives.Delete in Sitecore.Kernel.dll to accomplish this:
However, I wasn’t completely satisfied with this approach, especially when it would require a substantial amount of copying and pasting of code — a practice that I vehemently abhor — and decided to seek out a different, if not better, way of doing this.
From my research, I discovered that one could just create his/her own Archive class — it would have to ultimately derive from Sitecore.Data.Archiving.Archive in Sitecore.Kernel — which would delete a file on the filesystem associated with a Sitecore Item:
using System; using System.IO; using System.Linq; using System.Web; using Sitecore.Configuration; using Sitecore.Data; using Sitecore.Data.Archiving; using Sitecore.Data.DataProviders.Sql; using Sitecore.Diagnostics; namespace Sitecore.Sandbox.Data.Archiving { public class FileSystemHookSqlArchive : SqlArchive { private static readonly string FolderPath = GetFolderPath(); public FileSystemHookSqlArchive(string name, Database database) : base(name, database) { } public override void RemoveEntries(ArchiveQuery query) { DeleteFromFileSystem(query); base.RemoveEntries(query); } protected virtual void DeleteFromFileSystem(ArchiveQuery query) { if (query.ArchivalId == Guid.Empty) { return; } Guid itemId = GetItemId(query.ArchivalId); if (itemId == Guid.Empty) { return; } string filePath = GetFilePath(itemId.ToString()); if (string.IsNullOrWhiteSpace(filePath)) { return; } TryDeleteFile(filePath); } private void TryDeleteFile(string filePath) { try { if (File.Exists(filePath)) { File.Delete(filePath); } } catch (Exception ex) { Log.Error(this.ToString(), ex, this); } } public virtual Guid GetItemId(Guid archivalId) { if (archivalId == Guid.Empty) { return Guid.Empty; } ArchiveQuery query = new ArchiveQuery { ArchivalId = archivalId }; SqlStatement selectStatement = GetSelectStatement(query, "{0}ItemId{1}"); if (selectStatement == null) { return Guid.Empty; } return GetGuid(selectStatement.Sql, selectStatement.GetParameters(), Guid.Empty); } private Guid GetGuid(string sql, object[] parameters, Guid defaultValue) { using (DataProviderReader reader = Api.CreateReader(sql, parameters)) { if (!reader.Read()) { return defaultValue; } return Api.GetGuid(0, reader); } } private static string GetFilePath(string fileName) { string filePath = Directory.GetFiles(FolderPath, string.Concat(fileName, "*.*")).FirstOrDefault(); if (!string.IsNullOrWhiteSpace(filePath)) { return filePath; } return string.Empty; } private static string GetFolderPath() { return HttpContext.Current.Server.MapPath(Settings.GetSetting("FileSystemHookSqlArchive.Folder")); } } }
In the subclass of Sitecore.Data.Archiving.SqlArchive above — I’m using Sitecore.Data.Archiving.SqlArchive since I’m using SqlServer for my Sitecore instance — I try to find a file that is named after its associated Item’s ID — minus the curly braces — in a folder that I’ve mapped in a configuration include file (see below).
I first have to get the Item’s ID from the database using the supplied ArchivalId — this is all the calling code gives us, so we have to make do with what we have.
If the file exists, we try to delete it — we do this before letting the base class delete the Item from Recycle Bin so that we can retrieve the Item’s ID from the database before it’s removed from the Archive database table — and log any errors we encounter upon exception.
I then hooked in an instance of the above Archive class in a custom Sitecore.Data.Archiving.ArchiveProvider class:
using System.Xml; using Sitecore.Data; using Sitecore.Data.Archiving; using Sitecore.Xml; namespace Sitecore.Sandbox.Data.Archiving { public class FileSystemHookSqlArchiveProvider : SqlArchiveProvider { protected override Archive GetArchive(XmlNode configNode, Database database) { string attribute = XmlUtil.GetAttribute("name", configNode); if (string.IsNullOrEmpty(attribute)) { return null; } return new FileSystemHookSqlArchive(attribute, database); } } }
The above class — which derives from Sitecore.Data.Archiving.SqlArchiveProvider since I’m using SqlServer — only overrides its base class’s GetArchive factory method. We instantiate an instance of our Archive class instead of the “out of the box” Sitecore.Data.Archiving.SqlArchive class within it.
I then had to replace the “out of the box” Sitecore.Data.Archiving.ArchiveProvider reference, and define the location of our files in the following configuration file:
<?xml version="1.0" encoding="utf-8"?> <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <archives defaultProvider="sql" enabled="true"> <providers> <add name="sql" patch:instead="add[@type='Sitecore.Data.Archiving.SqlArchiveProvider, Sitecore.Kernel']" type="Sitecore.Sandbox.Data.Archiving.FileSystemHookSqlArchiveProvider, Sitecore.Sandbox" database="*"/> </providers> </archives> <settings> <setting name="FileSystemHookSqlArchive.Folder" value="/test/" /> </settings> </sitecore> </configuration>
Let’s test this out.
I first created a test Item to delete:
I then had to create a test file on the filesystem in my test folder — the test folder lives in my Sitecore instance’s website root:
I deleted the test Item from the content tree, opened up the Recycle Bin, selected the test Item, and got an itchy trigger finger — I want to delete the Item forever 🙂 :
After clicking the Delete button, I saw that the file on the filesystem was deleted as well:
If you have any thoughts on this, or recommendations around making it better, please leave a comment.
Navigate to Base Templates of a Template using a Sitecore Command
Have you ever said to yourself when looking at base templates of a template in its Content tab “wouldn’t it be great if I could easily navigate to one of these?”
I have had this thought more than once despite having the ability to do this in a template’s Inheritance tab — you can do this by clicking one of the base template links listed:
For some reason I sometimes forget you have the ability to get to a base template of a template in the Inheritance tab — why I forget is no doubt a larger issue I should try to tackle, albeit I’ll leave that for another day — and decided to build something that will be more difficult for me to forget: launching a dialog via a new item context menu option, and selecting one of the base templates of a template in that dialog.
I decided to atomize functionality in my solution by building custom pipelines/processors wherever I felt doing so made sense.
I started off by building a custom pipeline that gets base templates for a template, and defined a data transfer object (DTO) class for it:
using System.Collections.Generic; using Sitecore.Data.Items; using Sitecore.Pipelines; using Sitecore.Web.UI.Sheer; namespace Sitecore.Sandbox.Shell.Framework.Pipelines { public class GetBaseTemplatesArgs : PipelineArgs { public TemplateItem TemplateItem { get; set; } public bool IncludeAncestorBaseTemplates { get; set; } private List<TemplateItem> _BaseTemplates; public List<TemplateItem> BaseTemplates { get { if (_BaseTemplates == null) { _BaseTemplates = new List<TemplateItem>(); } return _BaseTemplates; } set { _BaseTemplates = value; } } } }
Client code must supply the template item that will be used as the starting point for gathering base templates, and can request all ancestor base templates — excluding the Standard Template as you will see below — by setting the IncludeAncestorBaseTemplates property to true.
I then created a class with a Process method that will serve as the only pipeline processor for my new pipeline:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Sitecore.Data.Items; using Sitecore.Diagnostics; namespace Sitecore.Sandbox.Shell.Framework.Pipelines { public class GetBaseTemplates { public void Process(GetBaseTemplatesArgs args) { Assert.ArgumentNotNull(args, "args"); Assert.ArgumentNotNull(args.TemplateItem, "args.TemplateItem"); List<TemplateItem> baseTemplates = new List<TemplateItem>(); GatherBaseTemplateItems(baseTemplates, args.TemplateItem, args.IncludeAncestorBaseTemplates); args.BaseTemplates = baseTemplates; } private static void GatherBaseTemplateItems(List<TemplateItem> baseTemplates, TemplateItem templateItem, bool includeAncestors) { if (includeAncestors) { foreach (TemplateItem baseTemplateItem in templateItem.BaseTemplates) { GatherBaseTemplateItems(baseTemplates, baseTemplateItem, includeAncestors); } } if (!IsStandardTemplate(templateItem) && templateItem.BaseTemplates != null && templateItem.BaseTemplates.Any()) { baseTemplates.AddRange(GetBaseTemplatesExcludeStandardTemplate(templateItem.BaseTemplates)); } } private static IEnumerable<TemplateItem> GetBaseTemplatesExcludeStandardTemplate(TemplateItem templateItem) { if (templateItem == null) { return new List<TemplateItem>(); } return GetBaseTemplatesExcludeStandardTemplate(templateItem.BaseTemplates); } private static IEnumerable<TemplateItem> GetBaseTemplatesExcludeStandardTemplate(IEnumerable<TemplateItem> baseTemplates) { if (baseTemplates != null && baseTemplates.Any()) { return baseTemplates.Where(baseTemplate => !IsStandardTemplate(baseTemplate)); } return baseTemplates; } private static bool IsStandardTemplate(TemplateItem templateItem) { return templateItem.ID == TemplateIDs.StandardTemplate; } } }
Methods in the above class add base templates to a list when the templates are not the Standard Template — I thought it would be a rare occurrence for one to navigate to it, and decided not to include it in the collection.
Further, the method that gathers base templates is recursively executed when client code requests all ancestor base templates be include in the collection.
The next thing I built was functionality to prompt the user for a base template via a dialog, and track which base template was chosen. I decided to do this using a custom client processor, and built the following DTO for it:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Sitecore.Web.UI.Sheer; using Sitecore.Data.Items; namespace Sitecore.Sandbox.Shell.Framework.Pipelines { public class GotoBaseTemplateArgs : ClientPipelineArgs { public TemplateItem TemplateItem { get; set; } public string SelectedBaseTemplateId { get; set; } } }
Just like the other DTO defined above, client code must suppy a template item. The SelectedBaseTemplateId property is set after a user selects a base template in the modal launched by the following class:
using System.Collections.Generic; using System.Linq; using Sitecore.Data.Items; using Sitecore.Data.Managers; using Sitecore.Diagnostics; using Sitecore.Pipelines; using Sitecore.Shell.Applications.Dialogs.ItemLister; using Sitecore.Web.UI.Sheer; namespace Sitecore.Sandbox.Shell.Framework.Pipelines { public class GotoBaseTemplate { public string SelectTemplateButtonText { get; set; } public string ModalIcon { get; set; } public string ModalTitle { get; set; } public string ModalInstructions { get; set; } public void SelectBaseTemplate(GotoBaseTemplateArgs args) { Assert.ArgumentNotNull(args, "args"); Assert.ArgumentNotNull(args.TemplateItem, "args.TemplateItem"); Assert.ArgumentNotNullOrEmpty(SelectTemplateButtonText, "SelectTemplateButtonText"); Assert.ArgumentNotNullOrEmpty(ModalIcon, "ModalIcon"); Assert.ArgumentNotNullOrEmpty(ModalTitle, "ModalTitle"); Assert.ArgumentNotNullOrEmpty(ModalInstructions, "ModalInstructions"); if (!args.IsPostBack) { ItemListerOptions itemListerOptions = new ItemListerOptions { ButtonText = SelectTemplateButtonText, Icon = ModalIcon, Title = ModalTitle, Text = ModalInstructions }; itemListerOptions.Items = GetBaseTemplateItemsForSelection(args.TemplateItem).Select(template => template.InnerItem).ToList(); itemListerOptions.AddTemplate(TemplateIDs.Template); SheerResponse.ShowModalDialog(itemListerOptions.ToUrlString().ToString(), true); args.WaitForPostBack(); } else if (args.HasResult) { args.SelectedBaseTemplateId = args.Result; args.IsPostBack = false; } else { args.AbortPipeline(); } } private IEnumerable<TemplateItem> GetBaseTemplateItemsForSelection(TemplateItem templateItem) { GetBaseTemplatesArgs args = new GetBaseTemplatesArgs { TemplateItem = templateItem, IncludeAncestorBaseTemplates = true, }; CorePipeline.Run("getBaseTemplates", args); return args.BaseTemplates; } public void Execute(GotoBaseTemplateArgs args) { Assert.ArgumentNotNull(args, "args"); Assert.ArgumentNotNullOrEmpty(args.SelectedBaseTemplateId, "args.SelectedBaseTemplateId"); Context.ClientPage.ClientResponse.Timer(string.Format("item:load(id={0})", args.SelectedBaseTemplateId), 1); } } }
The SelectBaseTemplate method above gives the user a list of base templates to choose from — this includes all ancestor base templates of a template minus the Standard Template.
The title, icon, helper text of the modal are supplied via the processor’s xml node in its configuration file — you’ll see this later on in this post.
Once a base template is chosen, its Id is then set in the SelectedBaseTemplateId property of the GotoBaseTemplateArgs instance.
The Execute method brings the user to the selected base template item in the Sitecore content tree.
Now we need a way to launch the code above.
I did this using a custom command that will be wired up to the item context menu:
using System.Collections.Generic; using System.Linq; using Sitecore.Data.Items; using Sitecore.Data.Managers; using Sitecore.Diagnostics; using Sitecore.Shell.Framework.Commands; using Sitecore.Sandbox.Shell.Framework.Pipelines; using Sitecore.Web.UI.Sheer; using Sitecore.Pipelines; namespace Sitecore.Sandbox.Commands { public class GotoBaseTemplateCommand : Command { public override void Execute(CommandContext context) { Context.ClientPage.Start("gotoBaseTemplate", new GotoBaseTemplateArgs { TemplateItem = GetItem(context) }); } public override CommandState QueryState(CommandContext context) { if (ShouldEnable(GetItem(context))) { return CommandState.Enabled; } return CommandState.Hidden; } private static bool ShouldEnable(Item item) { return item != null && IsTemplate(item) && GetBaseTemplates(item).Any(); } private static Item GetItem(CommandContext context) { Assert.ArgumentNotNull(context, "context"); Assert.ArgumentNotNull(context.Items, "context.Items"); return context.Items.FirstOrDefault(); } private static bool IsTemplate(Item item) { Assert.ArgumentNotNull(item, "item"); return TemplateManager.IsTemplate(item); } private static IEnumerable<TemplateItem> GetBaseTemplates(TemplateItem templateItem) { Assert.ArgumentNotNull(templateItem, "templateItem"); GetBaseTemplatesArgs args = new GetBaseTemplatesArgs { TemplateItem = templateItem, IncludeAncestorBaseTemplates = false }; CorePipeline.Run("getBaseTemplates", args); return args.BaseTemplates; } } }
The command above is visible only when the item is a template, and has base templates on it — we invoke the custom pipeline built above to get base templates.
When the command is invoked, we call our custom client processor to prompt the user for a base template to go to.
I then glued everything together using the following configuration file:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <sitecore> <commands> <command name="item:GotoBaseTemplate" type="Sitecore.Sandbox.Commands.GotoBaseTemplateCommand, Sitecore.Sandbox"/> </commands> <pipelines> <getBaseTemplates> <processor type="Sitecore.Sandbox.Shell.Framework.Pipelines.GetBaseTemplates, Sitecore.Sandbox"/> </getBaseTemplates> </pipelines> <processors> <gotoBaseTemplate> <processor mode="on" type="Sitecore.Sandbox.Shell.Framework.Pipelines.GotoBaseTemplate, Sitecore.Sandbox" method="SelectBaseTemplate"> <SelectTemplateButtonText>OK</SelectTemplateButtonText> <ModalIcon>Applications/32x32/nav_up_right_blue.png</ModalIcon> <ModalTitle>Select A Base Template</ModalTitle> <ModalInstructions>Select the base template you want to navigate to.</ModalInstructions> </processor> <processor mode="on" type="Sitecore.Sandbox.Shell.Framework.Pipelines.GotoBaseTemplate, Sitecore.Sandbox" method="Execute"/> </gotoBaseTemplate> </processors> </sitecore> </configuration>
I’ve left out how I’ve added the command shown above to the item context menu in the core database. For more information on adding to the item context menu, please see part one and part two of my post showing how to do this.
Let’s see how we did.
I first created some templates for testing. The following template named ‘Meta’ uses two other test templates as base templates:
I also created a ‘Base Page’ template which uses the ‘Meta’ template above:
Next I created ‘The Coolest Page Template Ever’ template — this uses the ‘Base Page’ template as its base template:
I then right-clicked on ‘The Coolest Page Template Ever’ template to launch its context menu, and selected our new menu option:
I was then presented with a dialog asking me to select the base template I want to navigate to:
I chose one of the base templates, and clicked ‘OK’:
I was then brought to the base template I had chosen:
If you have any thoughts on this, please leave a comment.
Content Manage Links to File System Favicons for Multiple Sites Managed in Sitecore
Earlier today someone started a thread in one of the SDN forums asking how to go about adding the ability to have a different favicon for each website managed in the same instance of Sitecore.
I had implemented this in the past for a few clients, and thought I should write a post on how I had done this.
In most of those solutions, the site’s start item would contain a “server file” field — yes I know it’s deprecated but it works well for this (if you can suggested a better field type to use, please leave a comment below) — that would point to a favicon on the file system:
Content authors/editors can then choose the appropriate favicon for each site managed in their Sitecore instance — just like this:
Not long after the SDN thread was started, John West — Chief Technology Officer at Sitecore USA — wrote a quick code snippet, followed by a blog post on how one might go about doing this.
John’s solution is a different than the one I had used in the past — each site’s favicon is defined on its site node in the Web.config.
After seeing John’s solution, I decided I would create a hybrid solution — the favicon set on the start item would have precedence over the one defined on the site node in the Web.config. In other words, the favicon defined on the site node would be a fallback.
For this hybrid solution, I decided to create a custom pipeline to retrieve the favicon for the context site, and created the following pipeline arguments class for it:
using System.Web.UI; using Sitecore.Pipelines; namespace Sitecore.Sandbox.Pipelines.GetFavicon { public class FaviconTryGetterArgs : PipelineArgs { public string FaviconUrl { get; set; } public Control FaviconControl{ get; set; } } }
The idea is to have pipeline processors set the URL of the favicon if possible, and have another processor create an ASP.NET control for the favicon when the URL is supplied.
The following class embodies this high-level idea:
using System; using System.Web.UI; using System.Web.UI.HtmlControls; using Sitecore; using Sitecore.Configuration; using Sitecore.Data.Fields; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Sandbox.Utilities.Extensions; namespace Sitecore.Sandbox.Pipelines.GetFavicon { public class FaviconTryGetter { private string FaviconFieldName { get; set; } public void TryGetFromStartItem(FaviconTryGetterArgs args) { Assert.ArgumentNotNull(args, "args"); bool canProcess = !string.IsNullOrWhiteSpace(FaviconFieldName) && Context.Site != null && !string.IsNullOrWhiteSpace(Context.Site.StartPath); if (!canProcess) { return; } Item startItem = Context.Database.GetItem(Context.Site.StartPath); args.FaviconUrl = startItem[FaviconFieldName]; } public void TryGetFromSite(FaviconTryGetterArgs args) { Assert.ArgumentNotNull(args, "args"); bool canProcess = Context.Site != null && string.IsNullOrWhiteSpace(args.FaviconUrl); if (!canProcess) { return; } /* GetFavicon is an extension method borrowed from John West. You can find it at http://www.sitecore.net/Community/Technical-Blogs/John-West-Sitecore-Blog/Posts/2013/08/Use-Different-Shortcut-Icons-for-Different-Managed-Sites-with-the-Sitecore-ASPNET-CMS.aspx */ args.FaviconUrl = Context.Site.GetFavicon(); } public void TryGetFaviconControl(FaviconTryGetterArgs args) { Assert.ArgumentNotNull(args, "args"); if(string.IsNullOrWhiteSpace(args.FaviconUrl)) { return; } args.FaviconControl = CreateNewFaviconControl(args.FaviconUrl); } private static Control CreateNewFaviconControl(string faviconUrl) { Assert.ArgumentNotNullOrEmpty(faviconUrl, "faviconUrl"); HtmlLink link = new HtmlLink(); link.Attributes.Add("type", "image/x-icon"); link.Attributes.Add("rel", "icon"); link.Href = faviconUrl; return link; } } }
The TryGetFromStartItem method tries to get the favicon set on the favicon field on the start item — the name of the field is supplied via one of the processors defined in the configuration include file below — and sets it on the FaviconUrl property of the FaviconTryGetterArgs instance supplied by the caller.
If the field name for the field containing the favicon is not supplied, or there is something wrong with either the context site or the start item’s path, then the method does not finish executing.
The TryGetFromSite method is similar to what John had done in his post. It uses the same exact extension method John had used for getting the favicon off of a “favicon” attribute set on the context site’s node in the Web.config — I have omitted this extension method and its class since you can check it out in John’s post.
If a URL is set by either of the two methods discussed above, the TryGetFaviconControl method creates an instance of an HtmlLink System.Web.UI.HtmlControls.HtmlControl, sets the appropriate attributes for an html favicon link tag, and sets it in the FaviconControl property of the FaviconTryGetterArgs instance.
I assembled the methods above into a new getFavicon pipeline in the following configuration include file, and also set a fallback favicon for my local sandbox site’s configuration element:
<?xml version="1.0" encoding="utf-8"?> <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <pipelines> <getFavicon> <processor type="Sitecore.Sandbox.Pipelines.GetFavicon.FaviconTryGetter, Sitecore.Sandbox" method="TryGetFromStartItem"> <FaviconFieldName>Favicon</FaviconFieldName> </processor> <processor type="Sitecore.Sandbox.Pipelines.GetFavicon.FaviconTryGetter, Sitecore.Sandbox" method="TryGetFromSite" /> <processor type="Sitecore.Sandbox.Pipelines.GetFavicon.FaviconTryGetter, Sitecore.Sandbox" method="TryGetFaviconControl" /> </getFavicon> </pipelines> <sites> <site name="website"> <patch:attribute name="favicon">/sitecore.ico</patch:attribute> </site> </sites> </sitecore> </configuration>
Just as John West had done in his post, I created a custom WebControl for rendering the favicon, albeit the following class invokes our new pipeline above to get the favicon ASP.NET control:
using System.Web.UI; using Sitecore.Pipelines; using Sitecore.Sandbox.Pipelines.GetFavicon; using Sitecore.Web.UI; namespace Sitecore.Sandbox.WebControls { public class Favicon : WebControl { protected override void DoRender(HtmlTextWriter output) { FaviconTryGetterArgs args = new FaviconTryGetterArgs(); CorePipeline.Run("getFavicon", args); if (args.FaviconControl != null) { args.FaviconControl.RenderControl(output); } } } }
If a favicon Control is supplied by our new getFavicon pipeline, the WebControl then delegates rendering responsibility to it.
I then defined an instance of the WebControl above in my default layout:
<%@ Register TagPrefix="sj" Namespace="Sitecore.Sharedsource.Web.UI.WebControls" Assembly="Sitecore.Sharedsource" %> ... <html> <head> ... <sj:Favicon runat="server" /> ...
For testing, I found a favicon generator website out on the internet — I won’t share this since it’s appeared to be a little suspect — and created a smiley face favicon. I set this on my start item, and published:
After clearing it out on my start item, and publishing, the fallback Sitecore favicon appears:
When you remove all favicons, none appear.
If you have any thoughts, suggestions, or comments on this, please share below.