Recently I found myself in a situation where using clones in Sitecore seemed like a good idea. We had a large product item tree on a main site that we also wanted to use on a number of sub sites so we went with clones.
Historically trying to maintain anything else than just one clone of an item tree was painful, as the editors manually had to go to each of the clone items to accept any changes done on the cloned items and when dealing with a number of clone items they would have to do it on each one. To remedy this the only alternative was to try to implement some kind of custom code to accept the changes automatically.
Well luckily, Sitecore finally came to the realization that perhaps this was not the way to make editors love their product and they gave us the ItemCloning.ForceUpdate setting that should handle all of these changes automatically. However, for some weird reason as I soon learned, but should have expected, it is a setting that comes with a Sitecore twist. Naturally I would have expected that it did what most people would expect and pretty much kept the clone item tree in sync with the cloned item tree, but boy was I wrong.
What I found was that if ForceUpdate is set to false then Sitecore will add a notification to the clone item tree when a cloned item is moved giving the editor the choice to accept the changes. The same happens when you copy or duplicate a cloned item. This is pretty much as one would expect.
However if you set ForceUpdate to true then the behavior becomes unclear. If you move a cloned item it will still add a notification to the clone item tree instead of automatically accepting the change. Here I would have expected that the changes were accepted automatically but for some reason Sitecore has decided that moving an item is not covered by ForceUpdate.
When it comes to copying or duplicating a cloned item the behavior becomes even more unexplainable. Not only is the change not automatically accepted but a notification is not even added to the clone item tree. This looks more like a logical error than a deliberate choice on Sitecore’s part as it leaves the editors with no options to actually accept the changes.
Well instead of spending a bunch of time wondering about whether this was the planned behavior or not, I started up the good old reflector and Google and got to work on making it behave the way I would expect it to work.
Automatically accepting the changes when an item is moved is not too hard to solve, as it simply requires implementing the code that we historically had to implement to allow any changes automatically. Sitecore has a knowledge base article on the subject here https://kb.sitecore.net/articles/820842.
using Sitecore.Configuration; using Sitecore.Data; using Sitecore.Data.Clones; using Sitecore.Data.DataProviders.Sql; using Sitecore.Data.Managers; using Sitecore.Globalization; namespace Cloning { public class SqlServerNotificationProvider : Sitecore.Data.DataProviders.SqlServer.SqlServerNotificationProvider { public SqlServerNotificationProvider(string connectionStringName, string databaseName) : base(connectionStringName, databaseName) { } protected SqlServerNotificationProvider(SqlDataApi api, string databaseName) : base(api, databaseName) { } public override void AddNotification(Notification notification) { if (notification is ItemMovedNotification && Settings.ItemCloning.ForceUpdate) { notification.Accept(ItemManager.GetItem(notification.Uri.ItemID, Language.Current, notification.Uri.Version, Database.GetDatabase(notification.Uri.DatabaseName))); return; } base.AddNotification(notification); } } } In the custom SqlServerNotificationProvider I am simply handling the ItemMovedNotification manually and accepting it if the ForceUpdate setting is true. With this in place, all I had to do was to replace Sitecore’s standard SqlServerNotificationProvider on the master database with my own. <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <databases> <database id="master"> <NotificationProvider type="Sitecore.Data.DataProviders.$(database).$(database)NotificationProvider, Sitecore.Kernel"> <patch:attribute name="type">Cloning.SqlServerNotificationProvider, Cloning</patch:attribute> </NotificationProvider> </database> </databases> </sitecore> </configuration>
Now on to dealing with copying or duplicating items. This was a tough one to solve and in the end I had to rely on Sitecore support to point me in the right direction so a big thank you goes out to the wonderful people who slave endlessly to help all of us – you are truly appreciated.
It turned out that the only solution was to implement my own custom notification manager.
using System.Collections.Generic; using System.Linq; using Sitecore; using Sitecore.Configuration; using Sitecore.Data; using Sitecore.Data.Clones; using Sitecore.Data.Engines.DataCommands; using Sitecore.Data.Events; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Globalization; using Sitecore.Links; namespace Cloning { public class CloneNotificationManager { public static void Initialize() { if (_initialized) return; lock (GlobalLock) { if (_initialized) return; try { foreach (Database database in Factory.GetDatabases()) { AttachCopiedItemEvent(database); } } finally { _initialized = true; } } } private static void AttachCopiedItemEvent(Database database) { Assert.ArgumentNotNull(database, "database"); if (database.NotificationProvider == null) return; database.Engines.DataEngine.CopiedItem += DataEngine_ItemCopied; } private static void DataEngine_ItemCopied(object sender, ExecutedEventArgs e) { Assert.ArgumentNotNull(e, "e"); HandleItemCopiedEvent(e.Command.Destination, e.Command.CopyId); } private static void HandleItemCopiedEvent(Item destination, ID childId) { Assert.ArgumentNotNull(destination, "destination"); Assert.ArgumentNotNull(childId, "childId"); IEnumerable destinationClones = GetDestinationClones(destination); foreach (Item clone in destinationClones) { ChildCreatedNotification childCreatedNotification = new ChildCreatedNotification { Uri = new ItemUri(clone.ID, clone.Paths.FullPath, Language.Invariant, Version.Latest, clone.Database.Name), ChildId = childId }; childCreatedNotification.Accept(clone); } } private static IEnumerable GetDestinationClones(Item source) { Assert.ArgumentNotNull(source, "source"); ItemLink[] array = Globals.LinkDatabase.GetReferrers(source, FieldIDs.Source); ItemLink[] second = array.Length > 0 ? new ItemLink[0] : Globals.LinkDatabase.GetReferrers(source, FieldIDs.SourceItem); array = array.Concat(second).ToArray(); ItemComparer itemComparer = new ItemComparer(); return array.Select(itemLink => itemLink.GetSourceItem()).Where(item => item != null && item.SourceUri != null && item.SourceUri.ItemID == source.ID).Distinct(itemComparer); } private static readonly object GlobalLock = new object(); private static bool _initialized; } }
The custom notification manager attaches an event listener to the CopiedItem event on all databases that have a notification provider and the event listener calls the method HandleItemCopiedEvent whenever an item is copied – this also covers duplication. The HandleItemCopiedEvent uses the link index to locate all clone items of the destination item that the item was copied or duplicated to. If the destination item has any clones a new ChildCreatedNotification object is created for each of them with a reference to the copied or duplicated item and the ChildCreatedNotification is accepted to accept the changes automatically. Please note that in the GetDestinationClones method I am using a custom ItemComparer that simply compares the id of two items to determine equality.
using System.Collections.Generic; using Sitecore.Data.Items; namespace Cloning { public class ItemComparer : IEqualityComparer { public bool Equals(Item x, Item y) { return x.ID.ToString().Equals(y.ID.ToString()); } public int GetHashCode(Item obj) { return obj.GetHashCode(); } } }
The custom notification manager now has to be initialized so that it can attach to the CopiedItem even of the databases and in order to do this I created the following InitializeCloneNotificationManager class.
using Sitecore; using Sitecore.Configuration; using Sitecore.Data.Managers; using Sitecore.Pipelines; namespace Cloning { [UsedImplicitly] public class InitializeCloneNotificationManager { [UsedImplicitly] public void Process(PipelineArgs args) { if (!Settings.ItemCloning.ForceUpdate) return; CloneNotificationManager.Initialize(); } } }
The InitializeCloneNotificationManager class simply checks if the ItemCloning.ForceUpdate setting is set to true and if so it calls the Initialize method on the CloneNotificationManager. The reason it is only initializing the CloneNotificationManager if the ForceUpdate setting is set to true is that, as I mentioned earlier, when it is set to false Sitecore will correctly add a notification to the clone destination item when an item is copied or duplicated. It is only required to run when ForceUpdate is set to true since Sitecore neither adds a notification nor accepts it automatically which means that we have to handle it.
Lastly I had to add my InitializeCloneNotificationManager to the startup right after Sitecore’s standard NotificationManager is initialized which happens in the initializeManagers pipeline.
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <pipelines> <initializeManagers> <processor patch:after="*[@type='Sitecore.Pipelines.InitializeManagers.InitializeNotificationManager, Sitecore.Kernel']" type="Cloning.InitializeCloneNotificationManager, Cloning"/> </initializeManagers> </pipelines> </sitecore> </configuration>
With my new SqlServerNotificationProvider and NotificationManager in place, Sitecore behaves, as I and hopefully others would expect when ItemCloning.ForceUpdate is set to true. Now when a cloned item is moved any clone items are moved as well and when a cloned item is copied or duplicated the new item is automatically cloned to all the clone item trees.
I hope this will prove useful to some and I also hope that at some point Sitecore will implement the same logic to make the ForceUpdate setting a bit more predictable.