Add a proper LastModified field to Sitecore

To track item changes Sitecore uses the “__Updated” field in the Statistics section. It’s a normal Datetime field i.e. it contains a date for every language and version. This is fine in most cases, but sometimes you need to track changes a bit further. For example when you change a shared field, you’re applying a change to all languages and versions. So how do you track those changes?

An image in the Media Library is a typical example of this problem. If you change the image, you’ve changed the item on all languages, but if you just add a translation to the “ALT” text, you’ve only changed that particular version.

Actually, I think Sitecore has done a bit of an ugly fix to this problem in the MediaRequestHandler. Possibly in other areas as well. The MediaRequestHandler uses the MediaData.Updated property to set the HTTP LastModified header, checks for modifications etc. That property just looks at the current version, and if missing, gets the maximum value of all versions. Given the above, this value can be very wrong.

Let’s say I change the “ALT” text, the MediaRequestHandler believes the image has changed. Fine, I can live with that. But worse is if I’ve actually changed the image, having another language as current, the MediaRequestHandler may give an end user a “304 not modified” despite it’s changed. Finally, the LastModified data may result in different values, for the same media URL, depending on the current language.

The same kind of issues may apply to all kinds of items where we have shared fields. Remember that fields like “__Renderings” are also shared. On the other hand, some shared fields may not have any effect on the end result. A change of the value in the “__Insert Rules” field would probably not have any implications from a content delivery perspective.

So, I decided to fix this once and for all. I’ve created a small piece of code that solves it by hooking into the “item:saving” event. I created a base template with just one shared LastModified Datetime field. By adding this base template to any other template, the LastModified field will be automatically updated. I placed the field in a Statistics section, equivalent to the existing one, so the fields ends up among the other standard fields.

You can add this base template to any template you like. The “File” template is typical one.

The code will basically just check if we’re saving an item in the master database that inherits our custom template. If so, it’ll check for any modifications in shared fields. If there is a change, the specified LastModified field will get updated as well.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;

namespace Stendahls.SitecoreExtensions.Events
{
	/// <summary>
	/// This event handler typically triggers on saving an item in the master database.
	/// It checks if the item implements the LastModifiedShared template and if so, 
	/// checks if any shared field is changed, the last modified date is updated.
	/// This is useful for cache keys etc.
	/// </summary>
	public class LastModifiedSavingEvent
	{
		public string Database { get; set; }
		public Guid BaseTemplate { get; set; }
		public string LastModifiedFieldName { get; set; }
		private readonly List<string> _ignoreFields = new List<string>();

		public List<string> IgnoreFields { get { return _ignoreFields; } }

		protected virtual bool IsOfBaseTemplate(TemplateItem template)
		{
			if (template.ID.Guid == BaseTemplate)
				return true;
			return template.BaseTemplates.Any(IsOfBaseTemplate);
		}

		public void OnItemSaving(object sender, EventArgs args)
		{
			var eventArgs = args as Sitecore.Events.SitecoreEventArgs;
			Assert.IsNotNull(eventArgs, "eventArgs");

			// Get the item we're updating
			var updatedItem = eventArgs.Parameters[0] as Item;
			Assert.IsNotNull(updatedItem, "item");

			// Make sure we're in the configured database
			if (updatedItem.Database != null && !String.Equals(updatedItem.Database.Name, Database, StringComparison.InvariantCultureIgnoreCase))
			{
				return;
			}

			// Make sure the module is properly configured
			if (string.IsNullOrWhiteSpace(LastModifiedFieldName) || BaseTemplate == Guid.Empty)
			{
				return;
			}

			// Make sure we're editing an item that implements the LastModified template
			if (!IsOfBaseTemplate(updatedItem.Template))
			{
				return;
			}

			// Make sure the LastModified field is also excluded
			if (!IgnoreFields.Contains(LastModifiedFieldName, StringComparer.InvariantCultureIgnoreCase))
				IgnoreFields.Add(LastModifiedFieldName);

			// Check if we've changed a shared field
			updatedItem.Fields.ReadAll();
			bool isUpdated = false;
			foreach (Field field in updatedItem.Fields)
			{
				if (field.IsModified && field.Shared && !IgnoreFields.Contains(field.Name, StringComparer.InvariantCultureIgnoreCase))
				{
					Log.Info(string.Format("Field {0} is modified to '{1}'. Updating LastModified", field.Name, field.Value), this);
					isUpdated = true;
					break;
				}
			}

			if (isUpdated)
			{
				updatedItem[LastModifiedFieldName] = DateUtil.IsoNow;
			}
		}
	}
}

And here is a sample configuration for it:

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
	<sitecore>
		<events>
			<event name="item:saving">
				<handler type="Stendahls.SitecoreExtensions.Events.LastModifiedSavingEvent, Stendahls.SitecoreExtensions" method="OnItemSaving">
					<database>master</database>
					<baseTemplate>{3EF6DD07-4EBD-4B7C-B75E-1344C5CECD01}</baseTemplate>
					<lastModifiedFieldName>LastModified</lastModifiedFieldName>
					<ignoreFields hint="list">
						<field>__Context Menu</field>
						<field>__Editor</field>
						<field>__Editors</field>
						<field>__Hidden</field>
						<field>__Read Only</field>
						<field>__Ribbon</field>
						<field>__Subitems Sorting</field>
						<field>__Thumbnail</field>
						<field>__Help link</field>
						<field>__Insert Rules</field>
						<field>__Masters</field>
						<field>__Publish</field>
						<field>__Unpublish</field>
						<field>__Publishing groups</field>
						<field>__Never publish</field>
						<field>__Security</field>
						<field>__Quick Action Bar Validation Rules</field>
						<field>__Validate Button Validation Rules</field>
						<field>__Validator Bar Validation Rules</field>
						<field>__Workflow Validation Rules</field>
						<field>__Suppressed Validation Rules</field>
						<field>__Workflow</field>
						<field>__Default workflow</field>
					</ignoreFields>
				</handler>
			</event>
		</events>
	</sitecore> 
</configuration>

You probably want to revise the ignoreFields list to suit your needs. I just picked some that I thought shouldn’t change the LastModified field.

Now, when you have a reliable LastModified field, it’s up to you to do something useful with it. In my projects, I typically use it for giving a correct version number in URL’s in order to get accurate caching over CDN’s and I’ve overridden the MediaRequestHandler with a proper one.

Hope you find this useful!

Please let me know if you have any comments or suggestions.