Experience Sitecore ! | Creating "Change item ID" module. Part 2 - The implementation

Experience Sitecore !

More than 200 articles about the best DXP by Martin Miles

Creating "Change item ID" module. Part 2 - The implementation

In the first part of his blog post we have identified the requirements, designed the consequence of uiChangeIdForItem pipeline processors, as well as created necessary Sitecore plumbing for that. So now let's focus on actual business logic and processors implementation.

The implementation of processors:

  1. CheckPermissions
  2. VerifyUnsupportedTypes
  3. GetNewName
  4. CheckNumberOfReferences
  5. Serialize
  6. ChangeId
  7. Deserialize
  8. MoveChildren
  9. UpdateReferences
  10. RemoveOriginalItem


1. CheckPermissions
Let's check permissions first. I borrowed this processor from uiRename pipeline and slightly modified that.
public virtual void CheckPermissions(ClientPipelineArgs args)
{
    Assert.ArgumentNotNull(args, "args");
    if (!SheerResponse.CheckModified())
    {
        return;
    }

    Item obj = GetItem(args);
    if (obj == null)
    {
        HandleItemNotFound(args);
    }
    else
    {
        if (!obj.Access.CanRename() || obj.Appearance.ReadOnly)
        {
            Context.ClientPage.ClientResponse.Alert(Translate.Text("You do not have permission to change ID \"{0}\".", obj.DisplayName));
            args.AbortPipeline();
        }

        if (Context.IsAdministrator || !obj.Locking.IsLocked() || obj.Locking.HasLock())
        {
            return;
        }

        Context.ClientPage.ClientResponse.Alert(Translate.Text("You cannot edit this item because '{0}' has locked it.", obj.Locking.GetOwnerWithoutDomain()));
        args.AbortPipeline();
    }
}

2. VerifyUnsupportedTypes
At this stage I want to disallow changing IDs for template fields. By that moment I haven't resolved how to change fields accurately, so will avoid that for now,
public virtual void VerifyUnsupportedTypes(ClientPipelineArgs args)
{
    Assert.ArgumentNotNull(args, "args");

    Item item = GetItem(args);
    if (item == null)
    {
        HandleItemNotFound(args);
    }
    else
    {
        if (item.TemplateID == new ID("{455A3E98-A627-4B40-8035-E683A0331AC7}") || item.Template.Name == "Template field")
        {
            Context.ClientPage.ClientResponse.Alert("You cannot modify ID of template field, but you may do that for template itself");
            args.AbortPipeline();
        }
    }
}

3. GetNewName
Asking user to provide new ID in the popup input box and validate that input value.
public virtual void GetNewName(ClientPipelineArgs args)
{
    Assert.ArgumentNotNull(args, "args");

    string header = Translate.Text("Change ID");
    if (args.IsPostBack)
    {
        if (string.IsNullOrEmpty(args.Result) || args.Result == "null" || args.Result == "undefined")
            args.AbortPipeline();

        else if (args.Result.Trim().Length == 0)
        {
            Context.ClientPage.ClientResponse.Alert("The name cannot be blank.");
            Context.ClientPage.ClientResponse.Input("Enter new ID for the item:", string.Empty,
                ID_FORMAT, "'$Input' is not a valid ID.", ID_LENGTH, header);

            args.WaitForPostBack();
        }
        else
        {
            Item obj = GetItem(args);

            if (obj == null)
            {
                HandleItemNotFound(args);
            }
            else
            {
                Context.ClientPage.ServerProperties["NewID"] = args.Result;
                args.IsPostBack = false;
            }
        }
    }
    else
    {
        Item obj = GetItem(args);
        if (obj == null)
        {
            HandleItemNotFound(args);
        }
        else
        {
            Context.ClientPage.ClientResponse.Input("Enter new ID for the item:", obj.ID.ToString(), ID_FORMAT, 
                "'$Input' is not a valid ID.", ID_LENGTH, header);

            args.WaitForPostBack();
        }
    }
}

4. CheckNumberOfReferences
This processor is not mandatory and presents for the sake of user experience. 
public virtual void CheckNumberOfReferences(ClientPipelineArgs args)
{
    Assert.ArgumentNotNull((object)args, "args");
    if (args.IsPostBack)
    {
        if (args.Result == "yes")
        {
            return;
        }

        args.AbortPipeline();
    }
    else
    {
        Item obj = GetItem(args);
        if (obj == null)
        {
            HandleItemNotFound(args);
        }
        else
        {
            string checkLinksMessage = GetCheckLinksMessage(obj);
            if (string.IsNullOrEmpty(checkLinksMessage))
            {
                return;
            }

            SheerResponse.Confirm(checkLinksMessage);
            args.WaitForPostBack();
        }
    }
}
GetCheckLinksMessage is calculating number of references for the item we are changing ID for and prompts that operation may take a longer time once more than 100 references are found,
private static string GetCheckLinksMessage(Item item)
{
    Assert.ArgumentNotNull(item, "item");
    string str = string.Empty;
    if (GetLinks(item) > 100)
    {
        str = Translate.Text("This operation may take a long time to complete.\n\nAre you sure you want to continue?");
    }

    return str;
}

5. Serialize
That is where our main logic begins. Let's serialize original item first using Sitecore API so by default it will write under /Data/serialization folder outside web root.
public virtual void Serialize(ClientPipelineArgs args)
{
    Assert.ArgumentNotNull(args, "args");

    Item item = GetItem(args);

    if (item == null)
    {
        HandleItemNotFound(args);
    }
    else
    {
        try
        {
            Serializer.SerializeItem(item);
        }
        catch (Exception e)
        {
            Context.ClientPage.ClientResponse.Alert($"Failed to serialize item: {item.DisplayName}");
            args.AbortPipeline();
        }
    }
}

And Serializer class is quite simple static class:

public static class Serializer
{
    public static void SerializeItem(Item item)
    {
        if (item != null)
        {
            Manager.DumpItem(item);
        }
    }

    public static void RestoreItem(string serializedItemPath, Database database)
    {
        var options = new LoadOptions(database) {ForceUpdate = true};

        using (new Sitecore.SecurityModel.SecurityDisabler())
        {
            Manager.LoadItem(serializedItemPath, options);
        }
    }
}

6. ChangeId
Then need to locate serialized file and modify it. In order to open I am again relying on Sitecore API by calling PathUtils.GetFilePath(new ItemReference(item)) method so it will return me the same path item was previously serialized. Then I read the file and simply replace old ID to the new ID and save it back. 
public virtual void ChangeId(ClientPipelineArgs args)
{
    Assert.ArgumentNotNull(args, "args");

    string newId = StringUtil.GetString(Context.ClientPage.ServerProperties["NewID"]);
    if (string.IsNullOrEmpty(newId) || newId == "null" || newId == "undefined")
    {
        return;
    }

    var item = GetItem(args);

    if (item == null)
    {
        HandleItemNotFound(args);
    }
    else
    {
        try
        {
            string serializedItemPath = GetSerializationPath(item);

            if (File.Exists(serializedItemPath))
            {
                string text = File.ReadAllText(serializedItemPath);

                text = text.Replace(item.ID.ToString(), newId);

                File.WriteAllText(serializedItemPath, text);
            }
        }
        catch (Exception e)
        {
            Context.ClientPage.ClientResponse.Alert("Cannot find serialized file");
            args.AbortPipeline();
        }
    }
}

7. Deserialize
Deserialize file back with modified ID. Sitecore allows multiple items with the same name at the same level so it will end up having two exactly the same items next to it other with new and old IDs.
public virtual void Deserialize(ClientPipelineArgs args)
{
    Assert.ArgumentNotNull(args, "args");

    Item item = GetItem(args);

    if (item == null)
    {
        HandleItemNotFound(args);
    }
    else
    {
        try
        {
            string serializedItemPath = GetSerializationPath(item);
            Serializer.RestoreItem(serializedItemPath, item.Database);
        }
        catch (Exception e)
        {
            Context.ClientPage.ClientResponse.Alert($"Failed to deserialize item: {item.DisplayName}");
            args.AbortPipeline();
        }
    }
}

8. MoveChildren
Since items can have children, the next step obviuosly would be to relocate all children recursively from underneath old item to become the children of a new item. Than especially makes sense when you're modifying a template item.
public virtual void MoveChildren(ClientPipelineArgs args)
{
    Assert.ArgumentNotNull(args, "args");
    var item = GetItem(args);
    if (item == null)
    {
        HandleItemNotFound(args);
    }
    else
    {
        try
        {
            //if (item.Paths.FullPath.StartsWith("/sitecore/templates"))
            {
                string newId = StringUtil.GetString(Context.ClientPage.ServerProperties["NewID"]);
                var newIdItem = item.Database.GetItem(new ID(newId));

                var children = item.Children;
                foreach (Item child in children)
                {
                    child.MoveTo(newIdItem);
                }                            
            }
        }
        catch (Exception e) 
        {
        }
    }
}

9. UpdateReferences
Apart from having child items, an item can be referenced by other items, the entire data relationship in Sitecore is built on that principle. The good news is that in recent versions of Sitecore references are GUIDs and no more paths. So what is now important - to go through all the referrers and update them with a GUID of new item. Remember step 4 where we made CheckNumberOfReferences processor that counted refs and prompted if there are too many? Those are the links to be modified at this stage:
public virtual void UpdateReferences(ClientPipelineArgs args)
{
    Assert.ArgumentNotNull(args, "args");

    var item = GetItem(args);
    if (item == null)
    {
        HandleItemNotFound(args);
    }
    else
    {
        try
        {
            string newId = StringUtil.GetString(Context.ClientPage.ServerProperties["NewID"]);
            Relink(item, newId);
        }
        catch (Exception e)
        {
            Context.ClientPage.ClientResponse.Alert("Error while updating links: " + e.Message);
            args.AbortPipeline();
        }
    }
}

10. RemoveOriginalItem
And finally, when everything else is done and we've got new item with all children and references modified. So it is good now o delete an original item
public virtual void RemoveOriginalItem(ClientPipelineArgs args)
{
    Assert.ArgumentNotNull(args, "args");

    Item item = GetItem(args);

    if (item == null)
    {
        HandleItemNotFound(args);
    }
    else
    {
        try
        {
            using (new SecurityDisabler())
            {
                item.Delete();
            }
        }
        catch (Exception e)
        {
            Context.ClientPage.ClientResponse.Alert("Error deliting initial item");
            args.AbortPipeline();
        }
    }
}  

There will be also final part of this blog post later that will add unit testing, sum up the results and highlight what can be done to make it better.

I will ask readers to forgive me at this stage for not giving a package with ChangeId module - haven't it properly tested yet. However anyone curious can clone it from GitHub repo and give a try yourself.

Comments are closed