Copy and Paste Items Server to Server

6

September 22, 2009 by Alistair Deneys

Sitecore has done a very good job of making item management in Sitecore very much like file management in your OS. Operations such as moving items, ordering items, copying and pasting items is as simple as a drag or a click. All this works quite well, but only for the current server you’re on.

If you have a look at the data on the clipboard after a copy command has run you’ll see it contains the ID of the item you wish to copy along with some meta data about the action to perform.

sitecore:copy:{39F265B4-1FF0-4DA2-BCA6-A3056D5BCCFE}

But often I find myself wanting to be able to copy an item from one server and paste it onto another. This normally happens when I want to pull specific content back from a QA or production server and paste it onto dev. Sitecore already provides a mechanism to do this; packages. But a package feels like a lot of effort when all I want is a single item (or tree).

So I got to working on a way in which to do this. My first thought was to alter the copy command to also place the URL of the server into the clipboard. Then provide a WCF service on the source server that the paste command could call to pull the data back. But after a little more consideration I worked out how to avoid the WCF service as well.

The Item class in Sitecore provides the ability to copy an item’s XML data, and create new items based on this XML through the GetOuterXml(bool) and Paste(string, bool, PasteMode) methods respectively. So if I could get the item’s entire data onto the clipboard, there would be no need for a WCF service.

First things first. Let’s create our custom copy command which will copy the item’s XML onto the clipboard. Our CopyItemXml command will extend Sitecore.Shell.Framework.Commands.ClipboardComman as we’ll be using the clipboard. We’ll also override the Execute and QueryState methods. The heavy lifting of the Execute method will be done in another method to allow us to run client pipelines to prompt and receive input from the user.

public override CommandState QueryState(CommandContext context)
{
  if (!ClipboardCommand.IsSupported(false))
    return CommandState.Hidden;

  if (context.Items == null || context.Items.Length == 0)
    return CommandState.Disabled;

  if (!context.Items[0].Access.CanRead())
    return CommandState.Disabled;

  return base.QueryState(context);
}

The QueryState method is called by Sitecore to determine what state to give the command in the UI such as hidden, disable or enabled.

public override void Execute(CommandContext context)
{
  if (ClipboardCommand.IsSupported(true) &&
    context.Items != null && context.Items.Length >= 1)
  {
    var parameters = new NameValueCollection();
    parameters.Add("id", context.Items[0].ID.ToString());
    parameters.Add("dbname", context.Items[0].Database.Name);
    parameters.Add("initialCall", "1");
    Context.ClientPage.Start(this, "Run", parameters);
  }
}

The Execute method does a few checks to ensure this command is supported by the browser (clipboard access) and then starts the client pipeline with the appropriate parameters.

protected void Run(ClientPipelineArgs args)
{
  if (args.Parameters["initialCall"] == "1")
  {
    args.Parameters["initialCall"] = "0";
    Context.ClientPage.ClientResponse.YesNoCancel(
      "Do you want to copy the children as well?", "200", "100");
    args.WaitForPostBack();
  }
  else
  {
    if (args.Result == "yes" || args.Result == "no")
    {
      var includeChildren = args.Result == "yes";
      var db = Sitecore.Configuration.Factory.GetDatabase(
        args.Parameters["dbname"]);

      if (db == null)
      {
        SheerResponse.Alert("Failed to locate target database");
        return;
      }

      var item = db.GetItem(args.Parameters["id"]);
      if (item == null)
      {
        SheerResponse.Alert("Failed to locate source item");
        return;
      }

      var data = item.GetOuterXml(includeChildren);
      data = data.Replace("\\", "\\\\");
      data = data.Replace("\"", "\\\"");
      data = EscapeEscapables(data);

      SheerResponse.Eval(
        "window.clipboardData.setData(\"Text\", \"" + data + "\")");
    }
  }
}

The Run method first checks to see if this is the first call to the method as a result of starting the client pipeline. If it is it sets into the response to prompt the user if they wish to also copy the children of the current item. If this is not the initial call then we grab the parameters, perform some error checking then find the source item and copy it’s XML data onto the clipboard. Note the formatting calls on the data variable towards the end of that method which will help escape the XML data to be shot back down to the browser in the response and executed in javascript. And here’s the EscapeEscapables util method which is also used.

private string EscapeEscapables(string input)
{
  var builder = new StringBuilder();
  builder.Append(input);
  builder.Replace("\r", "\\r");
  builder.Replace("\t", "\\t");
  builder.Replace("\n", "\\n");
  return builder.ToString();
}

Now we need to bind this command into the UI so we can execute it from the content editor. I’ll add this new command to the context menu. You could also add it to a ribbon if you liked.

Log into the Sitecore desktop and swap over to the core database. Next, open a content editor and navigate down to /sitecore/content/Applications/Content Editor/Context Menus/Default item. Here you can either create a new menu item definition, or copy the existing copy menu item definition. Either way the most important fields to fill in of the menu item are as follows:

Field Value
Display Name Copy XML
Icon Applications/16×16/copy.png
Message item:copyXMLtoclipboard(id=$Target)

The Message field specifies a command to execute when this menu item is clicked. This command name needs to map to our custom copy class above in the App_Config/commands.config file. Open that file and scroll down until you find the existing item:copy command, then copy that line and adjust it as follows:

<command name="item:copyXMLtoclipboard"
  type="CustomCommands.CopyItemXML,CustomCommands" />

That’s the first half done. We now have a custom command on the content editor context menu which can be used to copy an item’s XML data onto the clipboard.

copyitemxml

Now we have the data on the clipboard we need to create the paste menu item to handle pasting the item data onto a different server. But rather than creating a brand new menu item, command and command class, why not just extend the existing paste command to be able to deal with item XML data as well?

public class ExtendedItemPaste :
  Sitecore.Shell.Framework.Commands.PasteFromClipboard

I know the normal paste command above is structured very similarly to the copy command we created. So I can leave the QueryState method and Execute method alone and just override the Run method which is invoked as a result of the Execute method running a client pipeline.

protected new void Run(ClientPipelineArgs args)
{
  if (args.Parameters["fetched"] == "0")
  {
    args.Parameters["fetched"] = "1";
    Context.ClientPage.ClientResponse.Eval(
      "window.clipboardData.getData(\"Text\")").Attributes["response"] = "1";
    args.WaitForPostBack();
  }
  else
  {
    if (args.Result.StartsWith("<item "))
    {
      var db = Sitecore.Configuration.Factory.GetDatabase(
        args.Parameters["database"]);

      if (db == null)
      {
        SheerResponse.Alert("Failed to locate target database");
        return;
      }

      var parent = db.GetItem(args.Parameters["id"]);
      if (parent == null)
      {
        SheerResponse.Alert("Failed to locate parent item");
        return;
      }

      parent.Paste(args.Result, false, PasteMode.Merge);
    }
    else
      base.Run(args);
  }
}

In the paste method we need to use the ClientResponse.Eval method in conjunction with the ClientPipelineArgs.WaitForPostBack method to pull the data off the clipboard in javascript and return the result to our method. Next we check to see if the data from the clipboard is item XML or not. If it is we will handle it, otherwise let the default paste class handle it.

Now I have to override the existing command to use my new command class. Open the App_Config/commands.config file and find the command definition with name item:pastefromclipboard. Replace the type attribute with our ExtendedItemPaste command from above.

<command name="item:pastefromclipboard"
  type="CustomCommands.ExtendedItemPaste,CustomCommands" />

Now when we paste, the paste command will recognise the item XML and create new items based on that. To get the most out of this you’ll want to set up another server with these custom commands so you can copy from one server and paste to the other. If you paste onto the same server you’ll notice that no new item is created. This is because of the second parameter we’re passing to the Item.Paste method, changeIDs. If this parameter were true then the IDs in the XML would be replaced with new IDs and new items would be created. By not changing the IDs the existing item will have it’s data and fields updated from the XML. I left this parameter as false as the issue I am trying to solve with this solution is to be able to copy items from one server to another. I retain the original ID incase something else references this item by ID rather than name or path.

But if you really wanted new IDs you could change the paste code to set changeIDs to true, or even better, prompt the user using the same technique as used in the copy command to ask if they want new items or update existing (if found).

So my original issue is solved right? Well…if you’ve tried this technique using 2 separate and different servers you may have noticed 1 significant issue. If the template which the item is based on does not exist on the target server then my new item will not contain any fields.

missing template

So to have a full solution I should really grab the item’s template too. What about the template’s base template (and there could be multiple of those)? What about any references the item might use such as droplists or link fields to internal items? What about the presentation components used by the item (renderings, layouts, sublayouts, etc)? What about media? …

As you can see, once you start down the dependency paths you realise how big the graph can be. The easiest thing is to draw some definite boundries around what you will include in a copy and what you won’t. Below is a quick list of things that you might want to include and whether you easily can include them onto the clipboard as text.

Dependency Easily include as text?
Item’s template Yes
Item’s template’s base templates Yes
Linked items (references) Yes
Linked item’s templates Yes
Linked media library items No
Presentation definition items Yes
XSLT code Yes
Markup files (aspx, ascx) Yes
Code behind No

For my solution I’ll draw the following boundaries. I’ll copy the item (and children if required), the template hierarchies of the items and any item references. Basically just the data required to support my copied item.

The following shows my completed CopyItemXML command class.

using System.Collections.Generic;
using System.Collections.Specialized;
using System.Text;
using Sitecore;
using Sitecore.Data.Items;
using Sitecore.Shell.Framework.Commands;
using Sitecore.Web.UI.Sheer;

namespace CustomCommands
{
  public class CopyItemXML : ClipboardCommand
  {
    public override void Execute(CommandContext context)
    {
      if (ClipboardCommand.IsSupported(true) &&
        context.Items != null && context.Items.Length >= 1)
      {

        var parameters = new NameValueCollection();
        parameters.Add("id", context.Items[0].ID.ToString());
        parameters.Add("dbname", context.Items[0].Database.Name);
        parameters.Add("initialCall", "1");
        Context.ClientPage.Start(this, "Run", parameters);
      }
    }

    public override CommandState QueryState(CommandContext context)
    {
      if (!ClipboardCommand.IsSupported(false))
        return CommandState.Hidden;

      if (context.Items == null || context.Items.Length == 0)
        return CommandState.Disabled;

      if (!context.Items[0].Access.CanRead())
        return CommandState.Disabled;

      return base.QueryState(context);
    }

    protected void Run(ClientPipelineArgs args)
    {
      if (args.Parameters["initialCall"] == "1")
      {
        args.Parameters["initialCall"] = "0";
        Context.ClientPage.ClientResponse.YesNoCancel(
          "Do you want to copy the children as well?", "200", "100");
        args.WaitForPostBack();
      }
      else
      {
        if (args.Result == "yes" || args.Result == "no")
        {
          var includeChildren = args.Result == "yes";
          var db = Sitecore.Configuration.Factory.GetDatabase(
            args.Parameters["dbname"]);
          if (db == null)
          {
            SheerResponse.Alert("Failed to locate target database");
            return;
          }

          var item = db.GetItem(args.Parameters["id"]);
          if (item == null)
          {
            SheerResponse.Alert("Failed to locate source item");
            return;
          }

          var items = new List<Item>();
          var templates = new List<Item>();
          AddItems(item, includeChildren, true, items, templates);

          var data = GetXMLData(items, templates, item);
          data = data.Replace("\\", "\\\\");
          data = data.Replace("\"", "\\\"");
          data = EscapeEscapables(data);

          SheerResponse.Eval(
            "window.clipboardData.setData(\"Text\", \"" + data + "\")");
        }
      }
    }

    private void AddItems(Item item, bool includeChildren,
      bool includeReferences, List<Item> items, List<Item> templates)
    {
      // Add the item itself
      if (!ListContainsItem(items, item))
        items.Add(item);
 
      // Add any children (if required)
      if (includeChildren)
      {
        var children = item.GetChildren();
        for (int i = 0; i < children.Count; i++)
        {
          AddItems(children[i], includeChildren, false,
            items, templates);
        }
      }

      // Add references
      var links = item.Links.GetValidLinks();
      for (int i = 0; i < links.Length; i++)
      {
        var linkItem = links[i].SourceItemID == item.ID ?
          links[i].GetTargetItem() : links[i].GetSourceItem();

        var path = linkItem.Paths.FullPath.ToLower();
        if (!ListContainsItem(items, linkItem) && (
          path.StartsWith("/sitecore/content") ||
          path.StartsWith("/sitecore/templates")))
        {
          if (path.StartsWith("/sitecore/templates"))
            AddTemplateHeirarchy(linkItem, templates);
          else
            items.Add(linkItem);
        }
      }
    }

    private void AddTemplateHeirarchy(Item template, List<Item> items)
    {
      items.Add(template);
      items.AddRange(template.Axes.GetDescendants());
      var templateItem = (TemplateItem)template;
      for (int i = 0; i < templateItem.BaseTemplates.Length; i++)
        {
          if (templateItem.BaseTemplates[i].ID !=
            Sitecore.TemplateIDs.StandardTemplate &&
            !ListContainsItem(items, templateItem.BaseTemplates[i]))
            AddTemplateHeirarchy(templateItem.BaseTemplates[i], items);
        }
      }

    private bool ListContainsItem(List<Item> items,
      Item itemToFind)
    {
      return items.Exists(item => item.ID == itemToFind.ID);
    }

    private string GetXMLData(List<Item> items,
      List<Item> templates, Item rootItem)
    {
      var builder = new StringBuilder();
      builder.Append("<itemData>");
      builder.Append("<items>");

      for (int i = 0; i < items.Count; i++)
      {
        builder.Append("<itemData>");
        builder.Append("<path>");
        if (items[i].ID != rootItem.ID &&
          !items[i].Axes.IsDescendantOf(rootItem))
          builder.Append(items[i].Paths.FullPath);

        builder.Append("</path>");
        builder.Append(items[i].GetOuterXml(false));
        builder.Append("</itemData>");
      }

      builder.Append("</items>");
      builder.Append("<templates>");

      for (int i = 0; i < templates.Count; i++)
      {
        builder.Append("<itemData>");
        builder.Append("<path>");
        builder.Append(templates[i].Paths.FullPath);
        builder.Append("</path>");
        builder.Append(templates[i].GetOuterXml(false));
        builder.Append("</itemData>");
      }

      builder.Append("</templates>");
      builder.Append("</itemData>");
      return builder.ToString();
    }

    private string EscapeEscapables(string input)
    {
      var builder = new StringBuilder();
      builder.Append(input);

      builder.Replace("\r", "\\r");
      builder.Replace("\t", "\\t");
      builder.Replace("\n", "\\n");
      return builder.ToString();
    }
  }
}

You’ll see from above that I recursively (if required) collect the selected item and it’s descendants then on each item in the AddItems method I add any references as found in the links property of the item. For a good introduction to using the links database have a look at Mark Cassidy’s post Listing Related Articles with Sitecore using the LinkDatabase. Note how I’m only adding links under the content and templates sections of the content tree. The AddTemplateHeirarchy method adds the template hierarchy of the given item. When I construct the XML data I need to add the original path of any items not under the direct item being copied. Things like the templates and the references items need to be place in an appropriate location. I’ll use the path element in the paste command.

The following paste command is used to create items from this extended XML data block.

using System;
using System.Xml;
using Sitecore;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Web.UI.Sheer;

namespace CustomCommands
{
  public class ExtendedItemPaste :
    Sitecore.Shell.Framework.Commands.PasteFromClipboard
  {
    protected new void Run(ClientPipelineArgs args)
    {
      if (args.Parameters["fetched"] == "0")
      {
        args.Parameters["fetched"] = "1";
        Context.ClientPage.ClientResponse.Eval(
"window.clipboardData.getData(\"Text\")").Attributes["response"] = "1";
        args.WaitForPostBack();
      }
      else
      {
        if (args.Result.StartsWith("<itemData>"))
        {
          var db = Sitecore.Configuration.Factory.GetDatabase(
            args.Parameters["database"]);
          if (db == null)
          {
            SheerResponse.Alert("Failed to locate target database");
            return;
          }

          var parent = db.GetItem(args.Parameters["id"]);
          if (parent == null)
          {
            SheerResponse.Alert("Failed to locate parent item");
            return;
          }

          var doc = new XmlDocument();
          doc.LoadXml(args.Result);
          CreateTemplates(doc, db);
          CreateItems(doc, parent);
        }
        else
          base.Run(args);
      }
    }

    private void CreateTemplates(XmlDocument doc, Database database)
    {
      var templateNodes = doc.SelectNodes(
        "/itemData/templates/itemData");
      for (int i = 0; i < templateNodes.Count; i++)
      {
        var pathNode = templateNodes[i].SelectSingleNode("path");
        if (pathNode != null)
        {
          var path = pathNode.InnerText;
          var parent = GetParent(path, database);
          var itemNode = templateNodes[i].SelectSingleNode("item");

          if (itemNode != null)
            parent.Paste(itemNode.OuterXml, false, PasteMode.Merge);
        }
      }
    }

    private void CreateItems(XmlDocument doc, Item parent)
    {
      var itemNodes = doc.SelectNodes("/itemData/items/itemData");
      for (int i = 0; i < itemNodes.Count; i++)
      {
        var pathNode = itemNodes[i].SelectSingleNode("path");
        if (pathNode != null)
        {
          var path = pathNode.InnerText;
          var target = parent;
          var itemNode = itemNodes[i].SelectSingleNode("item");

          if (path != string.Empty)
            target = GetParent(path, parent.Database);
          else
          {
            if (itemNode != null)
            {
              var itemParent = parent.Database.GetItem(
                itemNode.Attributes["parentid"].Value);
              if (itemParent != null)
                target = itemParent;
            }
          }

          if (itemNode != null)
            target.Paste(itemNode.OuterXml, false, PasteMode.Merge);
        }
      }
    }

    private Item GetParent(string itemPath, Database database)
    {
      var parts = itemPath.Split(new string[]{"/"},
        StringSplitOptions.RemoveEmptyEntries);
      Item item = null;
      for (int i = 0; i < parts.Length - 1; i++) // Don't include last item
      {
        if (item == null)
          item = database.GetRootItem();
        else
        {
          var next = item.Axes.GetChild(parts[i]);
          if (next != null)
            item = next;
          else
            item = item.Add(parts[i],
              new TemplateID(Sitecore.TemplateIDs.Folder));
        }
      }

      return item;
    }
  }
}

In the Run method I load up the XML as an XML document which allows me to select the appropriate items out of the document for each operation. I also create the templates through the CreateTemplates method before I create the items which use the templates in the CreateItems method.

And there we have a slightly more robust solution.

Advertisements

6 thoughts on “Copy and Paste Items Server to Server

  1. jerrong says:

    Amazing Al,

    Such a good idea. There is another potential solution that I was looking into not too long ago and that was using the “Transfer items to another database” in the control panel. However as you suggested this would require some type of web service. I think this solution is a great idea! I will see if they can get this into the Sitecore CMS next version.

    – Tim

  2. Alistair Deneys says:

    Thanks Tim,
    Though for this solution to be commercial I see a custom form (Sheer control) being displayed to the user when they copy the item to ask which dependencies to include such as I describe in the article. Would be very cool if Sitecore included this in the next version šŸ™‚

  3. Kris says:

    I tried implementing this but I’m getting this error:

    “The data on the clipboard is not valid.

    Try copying the data again.”

  4. Kris says:

    Ignore my last comment, I forgot to copy the code up to the server I was pasting to. Works perfectly!

  5. Bryan says:

    Alistair, how is this different from setting up another server as a publishing target and then just publishing that item? Aside from not having to set up a new publishing target.

    • Alistair Deneys says:

      I thought this tweak would be better suited for copying items from QA and pasting back to dev. Also publishing targets don’t work too well when you’re publishing into a publishing source. I wrote about what you would have to do to tweak Sitecore to have the publishing targets and sources play nicely in this scenario.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Categories

The views expressed on this blog are solely my own and do not necessarily reflect the views of my employer.
%d bloggers like this: