Changing Sitecore item references when creating, copying, duplicating and cloning

Reason

When copying, duplicating or cloning an item tree (an item and its descendants) references in e.g. link, rich text and tree list fields remain unchanged. This makes sense as far as references to items “outside” the duplicated item tree are concerned, but references pointing “inward” should be modified to point to the original item’s counterpart amongst the copied items.

E.g.: The item “/sitecore/content/Original” is copied to “/sitecore/content/Copy”, along with all its descendants.
Assume that “Original” contains a link to the item “/sitecore/Content/Original/Child”: even though “/sitecore/content/Copy” has an equivalent child item (located at “/sitecore/content/Copy/Child”), the link in “Copy” is still referencing “/sitecore/content/Original/Child”.

Example - Original item

Example - Copy item

The method of duplication is irrelevant; copying, cloning and duplicating all behave the same.

An example of how rich text, link, tree list, layout and similar fields can be updated to reference the proper copies is outlined in the following.
Examples are based on Sitecore 6.6 and .NET 4.5.

Code

Reacting to “duplication-events” requires hooking into the pipelines uiDuplicateItem, uiCopyItems, uiCloneItems and the item:added event. The operational flow is outlined below:

  1. A pipeline processor creates, copies, duplicates or clones an item, or the item:added event is triggered.
  2. The source and target of the operation is passed to a ReferenceReplacementJob which uses the Sitecore.Jobs.JobManager to run asynchronously.
  3. An ItemPathTranslator is used to translate between source and target item paths.
  4. Pairs of source and target items, found using the translated paths, are added to an ItemReferenceReplacer.
  5. Once all pairs have been added, the reference replacer processes all “non-Sitecore fields” and the layout field (“__Renderings”). All ID’s, paths etc. from the “source item tree” are replaced with their equivalents from the “target item tree”.

To overwrite and/or amend the Web.config file with the required pipeline processors, save the following to a .config-file and place it in the “App_Config/Include”-folder. Replace namespace and assembly names as needed.

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <events>
      <event name="item:added">
        <handler type="NamespaceName.AddFromTemplate, AssemblyName" method="OnItemAdded" />
      </event>
    </events>

    <processors>
      <uiDuplicateItem>
        <processor mode="on" type="Sitecore.Shell.Framework.Pipelines.DuplicateItem,Sitecore.Kernel" method="Execute">
          <patch:attribute name="type">NamespaceName.DuplicateItem, AssemblyName</patch:attribute>
          <patch:attribute name="method">Execute</patch:attribute>
        </processor>
      </uiDuplicateItem>

      <uiCopyItems>
        <processor mode="on" type="NamespaceName.CopyOrCloneItem, AssemblyName" method="ProcessFieldValues" />
      </uiCopyItems>

      <uiCloneItems>
        <processor mode="on" type="NamespaceName.CopyOrCloneItem, AssemblyName" method="ProcessFieldValues" />
      </uiCloneItems>
    </processors>
  </sitecore>
</configuration>

The standard DuplicateItem processor has to be replaced with a slightly modified version, due to the lack of information it conveys to later pipeline steps: it’s not possible to determine the source and target of a “duplicate item”-operation. The standard implementation of the copy and clone-processors do not have this problem, which simplifies amendment of the corresponding pipelines.

The DuplicateItem-processor is based on the inner workings of Sitecore.Shell.Framework.Pipelines.DuplicateItem, with just enough modifications to create a ReferenceReplacementJob of off the source and target.

using Sitecore;
using Sitecore.Configuration;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Globalization;
using Sitecore.Web.UI.Sheer;

public class DuplicateItem
{
  private Item _itemToDuplicate;

  public new void Execute(ClientPipelineArgs args)
  {
    Item copy = Duplicate(args);
    if (copy == null)
      return;

    if (_itemToDuplicate == null)
      return;

    new ReferenceReplacementJob(_itemToDuplicate, copy).StartAsync();
  }

  private Item Duplicate(ClientPipelineArgs args)
  {
    Assert.ArgumentNotNull(args, "args");
    Item item = GetItemToDuplicate(args);
    _itemToDuplicate = item;
    if (item == null)
    {
      SheerResponse.Alert("Item not found.", new string[0]);
      args.AbortPipeline();
    }
    else
    {
      Item parent = item.Parent;
      if (parent == null)
      {
        SheerResponse.Alert("Cannot duplicate the root item.", new string[0]);
        args.AbortPipeline();
      }
      else if (parent.Access.CanCreate())
      {
        Log.Audit(this, "Duplicate item: {0}", new string[] { AuditFormatter.FormatItem(item) });
        return Context.Workflow.DuplicateItem(item, args.Parameters["name"]);
      }
      else
      {
        SheerResponse.Alert(Translate.Text("You do not have permission to duplicate \"{0}\".", new object[] { item.DisplayName }), new string[0]);
        args.AbortPipeline();
      }
    }
    return null;
  }

  private Item GetItemToDuplicate(ClientPipelineArgs args)
  {
    Language language;
    Database database = Factory.GetDatabase(args.Parameters["database"]);
    Assert.IsNotNull(database, args.Parameters["database"]);
    string str = args.Parameters["id"];
    if (!Language.TryParse(args.Parameters["language"], out language))
    {
      language = Context.Language;
    }
    return database.GetItem(ID.Parse(str), language);
  }
}

Both the copy and clone pipelines are provided with CopyItemsArgs containing source and target of the “duplication-event”, allowing for a clean extension of the respective pipelines (rather than forcing decompilation/rewriting of existing processors).

using System.Linq;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Shell.Framework.Pipelines;

public class CopyOrCloneItem : Sitecore.Shell.Framework.Pipelines.CopyItems
{
  public virtual void ProcessFieldValues(CopyItemsArgs args)
  {
    Item sourceRoot = base.GetItems(args).FirstOrDefault();
    Assert.IsNotNull(sourceRoot, "sourceRoot is null.");

    Item copyRoot = args.Copies.FirstOrDefault();
    Assert.IsNotNull(copyRoot, "copyRoot is null.");

    new ReferenceReplacementJob(sourceRoot, copyRoot).StartAsync();
  }
}

The following handler is courtesy of Wilhelm who suggested using the item:added event instead of the uiAddFromTemplate pipeline, which allows for clean extension and handling of branch-based item creation.

using Sitecore.Data.Items;
using Sitecore.Events;
using System;

public class AddFromTemplate
{
  public void OnItemAdded(object sender, EventArgs args)
  {
    Item targetItem = Event.ExtractParameter(args, 0) as Item;
    if (targetItem == null)
      return;
    if (targetItem.Branch == null)
      return;
    if (targetItem.Branch.InnerItem.Children.Count != 1)
      return;
    Item branchRoot = targetItem.Branch.InnerItem.Children[0];
    new ReferenceReplacementJob(branchRoot, targetItem).StartAsync();
  }
}

The ReferenceReplacementJob retrieves all source and target items using an ItemPathTranslator, and eventually lets an ItemReferenceReplacer process all target items.

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

public class ReferenceReplacementJob
{
  private readonly Item _source;
  private readonly Item _target;

  public ReferenceReplacementJob(Item source, Item target)
  {
    Assert.ArgumentNotNull(source, "source");
    Assert.ArgumentNotNull(target, "target");
    _source = source;
    _target = target;
  }

  public void StartAsync()
  {
    string jobCategory = typeof(ReferenceReplacementJob).Name;
    string siteName = Context.Site == null ? "No Site Context" : Context.Site.Name;
    JobOptions jobOptions = new JobOptions(GetJobName(), jobCategory, siteName, this, "Start");
    JobManager.Start(jobOptions);
  }

  private string GetJobName()
  {
    return string.Format("Resolving item references between source {0} and target {1}.", AuditFormatter.FormatItem(_source), AuditFormatter.FormatItem(_target));
  }

  public void Start()
  {
    ItemPathTranslator translator = new ItemPathTranslator(_source, _target);
    IEnumerable<Item> sourceDescendants = GetDescendantsAndSelf(_source);
    ItemReferenceReplacer replacer = InitializeReplacer(sourceDescendants, translator);
    foreach (Item equivalentTarget in replacer.OtherItems)
    {
      replacer.ReplaceItemReferences(equivalentTarget);
    }
  }

  private IEnumerable<Item> GetDescendantsAndSelf(Item source)
  {
    return Query.SelectItems("descendant-or-self::*", source) ?? Enumerable.Empty<Item>();
  }

  private ItemReferenceReplacer InitializeReplacer(IEnumerable<Item> sourceDescendants, ItemPathTranslator translator)
  {
    ItemReferenceReplacer replacer = new ItemReferenceReplacer(ExcludeStandardSitecoreFieldsExceptLayout);
    foreach (Item sourceDescendant in sourceDescendants)
    {
      if (!translator.CanTranslatePath(sourceDescendant))
        continue;

      Item equivalentTarget = sourceDescendant.Database.GetItem(translator.TranslatePath(sourceDescendant));
      if (equivalentTarget == null)
        continue;

      replacer.AddItemPair(sourceDescendant, equivalentTarget);
    }
    return replacer;
  }

  private bool ExcludeStandardSitecoreFieldsExceptLayout(Field field)
  {
    Assert.ArgumentNotNull(field, "field");
    return field.ID == FieldIDs.LayoutField || !field.Name.StartsWith("__");
  }
}

Item paths are “translated” relative to a common root item (e.g. a site root item in a multi-site solution, or the content root in a single-site solution).

using Sitecore;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Exceptions;

public class ItemPathTranslator
{
  private readonly Item _source;
  private readonly Item _sourceRoot;
  private readonly Item _target;
  private readonly Item _targetRoot;

  public ItemPathTranslator(Item source, Item target)
    : this(source, source, target, target)
  {
  }

  public ItemPathTranslator(Item source, Item sourceRoot, Item target, Item targetRoot)
  {
    Assert.ArgumentNotNull(source, "source");
    Assert.ArgumentNotNull(sourceRoot, "sourceRoot");
    Assert.ArgumentNotNull(target, "target");
    Assert.ArgumentNotNull(targetRoot, "targetRoot");
    _source = source;
    _sourceRoot = sourceRoot;
    _target = target;
    _targetRoot = targetRoot;
  }

  public bool CanTranslatePath(Item item)
  {
    Assert.ArgumentNotNull(item, "item");
    return IsDescendantOrSelf(item, _source) || IsDescendantOrSelf(item, _sourceRoot);
  }

  private bool IsDescendantOrSelf(Item item, Item otherItem)
  {
    return item.ID == otherItem.ID || item.Axes.IsDescendantOf(otherItem);
  }

  public string TranslatePath(Item item)
  {
    Assert.ArgumentNotNull(item, "item");
    if (IsDescendantOrSelf(item, _source))
      return GetFullPath(_target, _source, item);

    if (IsDescendantOrSelf(item, _sourceRoot))
      return GetFullPath(_targetRoot, _sourceRoot, item);

    throw new InvalidItemException(string.Format("Item {0} is not a descendant of {1} or {2}.",
      AuditFormatter.FormatItem(item),
      AuditFormatter.FormatItem(_source),
      AuditFormatter.FormatItem(_sourceRoot)));
  }

  private string GetFullPath(Item closestEquivalentAncestor, Item closestAncestor, Item item)
  {
    string startPathPart = closestEquivalentAncestor.Paths.FullPath;
    string relativePathPart = GetRelativePath(closestAncestor, item);
    return StringUtil.EnsurePostfix('/', startPathPart) + StringUtil.RemovePrefix('/', relativePathPart);
  }

  private string GetRelativePath(Item closestAncestor, Item item)
  {
    return item.Paths.FullPath.Replace(closestAncestor.Paths.FullPath, string.Empty);
  }
}

Item references are stored in different formats in Sitecores various field types, hence reference replacement is performed in four ways: ID, ShortID, full path and content path.
Using this “broad spectrum” approach allows proper replacement of references in all relevant  field types (general link, rich text, layout, tree list etc.).

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

public class ItemReferenceReplacer
{
  private readonly Func<Field, bool> _fieldFilter;
  private readonly ICollection<ItemPair> _itemPairs = new HashSet<ItemPair>();

  public IEnumerable<Item> Items
  {
    get { return _itemPairs.Select(pair => pair.Item).ToArray(); }
  }

  public IEnumerable<Item> OtherItems
  {
    get { return _itemPairs.Select(pair => pair.OtherItem).ToArray(); }
  }

  public ItemReferenceReplacer(Func<Field, bool> fieldFilter)
  {
    Assert.ArgumentNotNull(fieldFilter, "fieldFilter");
    _fieldFilter = fieldFilter;
  }

  public void AddItemPair(Item item, Item otherItem)
  {
    Assert.ArgumentNotNull(item, "item");
    Assert.ArgumentNotNull(otherItem, "otherItem");
    _itemPairs.Add(new ItemPair(item, otherItem));
  }

  public void ReplaceItemReferences(Item item)
  {
    IEnumerable<Field> fields = GetFieldsToProcess(item);
    foreach (Field field in fields)
    {
      foreach (Item itemVersion in GetVersionsToProcess(item))
      {
        Field itemVersionField = itemVersion.Fields[field.ID];
        ProcessField(itemVersionField);
      }
    }
  }

  private IEnumerable<Field> GetFieldsToProcess(Item item)
  {
    item.Fields.ReadAll();
    return item.Fields.Where(_fieldFilter).ToArray();
  }

  private IEnumerable<Item> GetVersionsToProcess(Item item)
  {
    return item.Versions.GetVersions(true);
  }

  private void ProcessField(Field field)
  {
    string initialValue = GetInitialFieldValue(field);
    if (string.IsNullOrEmpty(initialValue))
      return;

    StringBuilder value = new StringBuilder(initialValue);
    foreach (ItemPair itemPair in _itemPairs)
    {
      ReplaceID(itemPair.Item, itemPair.OtherItem, value);
      ReplaceShortID(itemPair.Item, itemPair.OtherItem, value);
      ReplaceFullPath(itemPair.Item, itemPair.OtherItem, value);
      ReplaceContentPath(itemPair.Item, itemPair.OtherItem, value);
    }
    UpdateFieldValue(field, initialValue, value);
  }

  private string GetInitialFieldValue(Field field)
  {
    return field.GetValue(true, true);
  }

  private void ReplaceID(Item item, Item otherItem, StringBuilder value)
  {
    value.Replace(item.ID.ToString(), otherItem.ID.ToString());
  }

  private void ReplaceShortID(Item item, Item otherItem, StringBuilder value)
  {
    value.Replace(item.ID.ToShortID().ToString(), otherItem.ID.ToShortID().ToString());
  }

  private void ReplaceFullPath(Item item, Item otherItem, StringBuilder value)
  {
    value.Replace(item.Paths.FullPath, otherItem.Paths.FullPath);
  }

  private void ReplaceContentPath(Item item, Item otherItem, StringBuilder value)
  {
    if (item.Paths.IsContentItem)
      value.Replace(item.Paths.ContentPath, otherItem.Paths.ContentPath);
  }

  private void UpdateFieldValue(Field field, string initialValue, StringBuilder value)
  {
    if (initialValue.Equals(value.ToString()))
      return;

    using (new EditContext(field.Item))
    {
      field.Value = value.ToString();
    }
  }

  [DebuggerDisplay("Item: \"{Item.Paths.Path}\", OtherItem: \"{OtherItem.Paths.Path}\"")]
  private class ItemPair
  {
    public Item Item { get; private set; }
    public Item OtherItem { get; private set; }

    public ItemPair(Item item, Item otherItem)
    {
      Assert.ArgumentNotNull(item, "item");
      Assert.ArgumentNotNull(otherItem, "otherItem");
      Item = item;
      OtherItem = otherItem;
    }

    public override bool Equals(object instance)
    {
      return instance is ItemPair && instance.GetHashCode() == GetHashCode();
    }

    public override int GetHashCode()
    {
      return string.Concat(Item.ID, OtherItem.ID).GetHashCode();
    }
  }
}

Example

Example of droptree and treelist field. The field contents have been properly updated by ReplaceID.

Example - droptree and treelist field in original

Example - droptree and treelist field in copy

Example of rich text and general link field. The field contents have been properly updated by ReplaceID, ReplaceShortID and ReplaceContentPath.

Example - rich text and general link in original

Example - rich text and general link in copy

Example of data source path in layout field. The field content has been properly updated by ReplaceFullPath. Data source IDs would also be replaced correctly, hence the replacement process is compatible with Convert Data Source Paths to IDs and similar solutions.

Example - layout field in original

Example - layout field in copy

Advertisements