This turned out to be a lot more challenging than I was expecting it to be. Duplicating a content type required that I learn a lot about how to programmatically create things like site columns and workflows associations and policies. I also had to do a lot of reverse engineering to figure out seemingly simple things such as how the document information panel and document templates are stored.
In some cases copying elements became as simple as adding xml to a collection or adding an object from the source collection to the target with basically no real logic needed. In other cases I had to painstakingly reconstruct the original element – considering the limited documentation on some of this stuff I’m not convinced it could have been done without Reflector – Microsoft does some rather odd things that I can’t seem to find documented anywhere.
I think this command, perhaps more than some of the others, has potential for the most use post deployment. If you’ve got several site collections and content types that you need duplicated across those site collections then you’ll want to check this out – it’s a real pain to have to recreate a content type more than once (it’s so easy to miss something when you consider all the custom columns, policies, workflows, templates, etc.). Of course if you built your content types using Features then you wouldn’t have this issue but I know that there are many that do not 🙂
The nice thing about this command is that it can be used to copy all content types from one site collection to another or just a single content type. The code to accomplish this consists of 7 key methods – the primary method, CreateContentType()
creates the content type itself based on the source content type. This method then calls out to the remaining six methods to add peripheral items such as workflows and templates. To create a new content type you have to make sure that the parent content type exists first – the CreateContentType()
method does a recursive call to address the Parent and then once all the parent types exist it will set the basic properties for the new content type.
1private static void CreateContentType(string targetUrl, SPContentType sourceCT, SPFieldCollection sourceFields, bool verbose)
2{
3 // Make sure any parent content types exist - they have to be there before we can create this content type.
4 if (availableTargetContentTypes[sourceCT.Parent.Id] == null)
5 {
6 Log(string.Format("Progress: Parent of content type '{0}' does not exist - creating...", sourceCT.Name));
7
8 CreateContentType(targetUrl, sourceCT.Parent, sourceFields, verbose);
9
10 // Reset the fields and content types.
11 GetAvailableTargetContentTypes(verbose, targetUrl);
12 }
13
14 Log(string.Format("Progress: Creating content type '{0}'...", sourceCT.Name));
15
16 // Create a new content type using information from the source content type.
17 SPContentType newCT = new SPContentType(availableTargetContentTypes[sourceCT.Parent.Id], targetContentTypes, sourceCT.Name);
18
19 Log(string.Format("Progress: Setting fields for content type '{0}'...", sourceCT.Name));
20
21 // Set all the core properties for the content type.
22 newCT.Group = sourceCT.Group;
23 newCT.Hidden = sourceCT.Hidden;
24 newCT.NewDocumentControl = sourceCT.NewDocumentControl;
25 newCT.NewFormTemplateName = sourceCT.NewFormTemplateName;
26 newCT.NewFormUrl = sourceCT.NewFormUrl;
27 newCT.ReadOnly = sourceCT.ReadOnly;
28 newCT.RequireClientRenderingOnNew = sourceCT.RequireClientRenderingOnNew;
29 newCT.Description = sourceCT.Description;
30 newCT.DisplayFormTemplateName = sourceCT.DisplayFormTemplateName;
31 newCT.DisplayFormUrl = sourceCT.DisplayFormUrl;
32 newCT.EditFormTemplateName = sourceCT.EditFormTemplateName;
33 newCT.EditFormUrl = sourceCT.EditFormUrl;
34
35 string xml = (string)Utilities.GetFieldValue(newCT, "m_strXmlNonLocalized");
36 xml = xml.Replace(string.Format("ID=\"{0}\"", newCT.Id), string.Format("ID=\"{0}\"", sourceCT.Id));
37 Utilities.SetFieldValue(newCT, typeof (SPContentType), "m_strXmlNonLocalized", xml);
38 Utilities.SetFieldValue(newCT, typeof(SPContentType), "m_id", sourceCT.Id);
39
40 Log(string.Format("Progress: Adding content type '{0}' to collection...", sourceCT.Name));
41
42 // Add the content type to the content types collection and update all the settings.
43 targetContentTypes.Add(newCT);
44 newCT.Update();
45
46 // Add all the peripheral items
47
48 try
49 {
50 if (copyColumns)
51 {
52 Log(string.Format("Progress: Adding site columns for content type '{0}'...", sourceCT.Name));
53
54 AddSiteColumns(newCT, sourceCT, sourceFields, verbose);
55 }
56
57 if (copyWorkflows)
58 {
59 Log(string.Format("Progress: Adding workflow associations for content type '{0}'...", sourceCT.Name));
60
61 AddWorkflowAssociations(newCT, sourceCT, verbose);
62 }
63
64 if (copyDocTemplate)
65 {
66 Log(string.Format("Progress: Adding document template for content type '{0}'...", sourceCT.Name));
67
68 AddDocumentTemplate(newCT, sourceCT);
69 }
70
71 if (copyDocConversions)
72 {
73 Log(string.Format("Progress: Adding document conversion settings for content type '{0}'...", sourceCT.Name));
74
75 AddDocumentConversionSettings(newCT, sourceCT);
76 }
77
78 if (copyPolicies)
79 {
80 Log(string.Format("Progress: Adding information rights policies for content type '{0}'...", sourceCT.Name));
81
82 AddInformationRightsPolicies(newCT, sourceCT, verbose);
83 }
84
85 if (copyDocInfoPanel)
86 {
87 Log(string.Format("Progress: Adding document information panel for content type '{0}'...", sourceCT.Name));
88
89 AddDocumentInfoPanelToContentType(sourceCT, newCT);
90 }
91 }
92 finally
93 {
94 newCT.ParentWeb.Site.Dispose();
95 newCT.ParentWeb.Dispose();
96 }
97}
After creating the content type and saving it the code then adds the other elements according to flags that can be passed in (the default is to copy everything but you can turn off individual items). The first thing I do is add any needed columns. A content type doesn’t actually store any details about columns (or fields as they are referred to in code) rather it stores a link to the columns. The columns themselves are part of the Fields
collection which you can get from an SPWeb object. So the first step in setting the columns for the content type is to create the columns if they don’t already exist and then link that column to the content type:
1private static void AddSiteColumns(SPContentType targetCT, SPContentType sourceCT, SPFieldCollection sourceFields, bool verbose)
2{
3 // Store the field order so that we can reorder after adding all the fields.
4 List<string> fields = new List<string>();
5 foreach (SPField field in sourceCT.Fields)
6 {
7 if (!field.Hidden && field.Reorderable)
8 fields.Add(field.InternalName);
9 }
10 // Add any columns associated with the content type.
11 foreach (SPFieldLink field in sourceCT.FieldLinks)
12 {
13 // First we need to see if the column already exists as a Site Column.
14 SPField sourceField;
15 try
16 {
17 // First try and find the column via the ID
18 sourceField = sourceFields[field.Id];
19 }
20 catch
21 {
22 try
23 {
24 // Couldn't locate via ID so now try the name
25 sourceField = sourceFields[field.Name];
26 }
27 catch
28 {
29 sourceField = null;
30 }
31 }
32 if (sourceField == null)
33 {
34 // Couldn't locate by ID or name - it could be due to casing issues between the linked version of the name and actual field
35 // (for example, the Contact content type stores the name for email differently: EMail for the field and Email for the link)
36 foreach (SPField f in sourceCT.Fields)
37 {
38 if (field.Name.ToLowerInvariant() == f.InternalName.ToLowerInvariant())
39 {
40 sourceField = f;
41 break;
42 }
43 }
44 }
45 if (sourceField == null)
46 {
47 Log(string.Format("WARNING: Unable to add column '{0}' to content type.", field.Name));
48 continue;
49 }
50
51 if (!targetFields.ContainsField(sourceField.InternalName))
52 {
53 Log(string.Format("Progress: Adding column '{0}' to site columns...", sourceField.InternalName));
54
55 // The column does not exist so add the Site Column.
56 targetFields.Add(sourceField);
57 }
58
59 // Now that we know the column exists we can add it to our content type.
60 if (targetCT.FieldLinks[sourceField.InternalName] == null) // This should always be true if we're here but I'm keeping it in as a safety check.
61 {
62 Log(string.Format("Progress: Associating content type with site column '{0}'...", sourceField.InternalName));
63
64 // Now add the reference to the site column for this content type.
65 try
66 {
67 targetCT.FieldLinks.Add(field);
68 }
69 catch (Exception ex)
70 {
71 Log("WARNING: Unable to add field '{0}' to content type: {1}", sourceField.InternalName, ex.Message);
72 }
73 }
74 }
75 // Save the fields so that we can reorder them.
76 targetCT.Update(true);
77
78 // Reorder the fields.
79 try
80 {
81 targetCT.FieldLinks.Reorder(fields.ToArray());
82 }
83 catch
84 {
85 Log("WARNING: Unable to set field order.");
86 }
87 targetCT.Update(true);
88}
After adding the columns I add the workflows (note that adding these peripheral items can be done in any order as long as the content type has been saved and therefore belongs to a content type collection). When I first started researching how to do this I thought I was going to have to do a whole lot of work to recreate the workflow (based on what I was seeing in Reflector and how Microsoft was handling the creation of workflows for a content type). On a whim I decided to simply try adding a workflow association object (SPWorkflowAssociation
) directly to the targets WorkflowAssociations
collection and to my surprise it worked perfectly. Only catch was that I had to make sure I cleared out any existing workflows on the target (the default items that are added behind the scenes when you create a content type):
1private static void AddWorkflowAssociations(SPContentType targetCT, SPContentType sourceCT, bool verbose)
2{
3 // Remove the default workflows - we're going to add from the source.
4 while (targetCT.WorkflowAssociations.Count > 0)
5 {
6 targetCT.RemoveWorkflowAssociation(targetCT.WorkflowAssociations[0]);
7 }
8
9 // Add workflows.
10 foreach (SPWorkflowAssociation wf in sourceCT.WorkflowAssociations)
11 {
12 Log(string.Format("Progress: Adding workflow '{0}' to content type...", wf.Name));
13
14 targetCT.AddWorkflowAssociation(wf);
15 }
16 targetCT.Update();
17}
Adding the document templates was another one that I thought was going to be a lot more difficult but in the end turned out pretty easy. Document templates (and custom document information panels) are stored in the resource folder which is located at http://yourdomain/yoursitecollection/_cts/yourcontenttypename/
. The SPContentType
object has a ResourceFolder
property which contains all the documents located here. To copy the template all I had to do was get a reference to the SPFile object returned by the ResourceFolder.Files
collection and then add it to my targets ResourceFolder.Files
collection:
1private static void AddDocumentTemplate(SPContentType targetCT, SPContentType sourceCT)
2{
3 if (string.IsNullOrEmpty(sourceCT.DocumentTemplate))
4 return;
5
6 // Add the document template.
7 SPFile sourceFile = null;
8 try
9 {
10 sourceFile = sourceCT.ResourceFolder.Files[sourceCT.DocumentTemplate];
11 }
12 catch (ArgumentException) {}
13 if (sourceFile != null && !string.IsNullOrEmpty(sourceFile.Name))
14 {
15 SPFile targetFile = targetCT.ResourceFolder.Files.Add(sourceFile.Name, sourceFile.OpenBinary(), true);
16 targetCT.DocumentTemplate = targetFile.Name;
17 targetCT.Update();
18 }
19 else
20 {
21 targetCT.DocumentTemplate = sourceCT.DocumentTemplate;
22 targetCT.Update();
23 }
24}
Adding the document conversion settings turned out to be only marginally more difficult than adding the document template settings. When I first started researching how to do this I was expecting an object for which I could set various properties. Unfortunately (and fortunately as it turned out) I was wrong. Microsoft effectively breaks the pattern here (as well as with the document information panel) by choosing to use an XmlDocument
object to store the settings. This threw me for a loop when trying to figure out how to do this but the result was that I could easily copy the settings by simply copying the XML from one object to another.
The SPContentType
object has an XmlDocuments
property which is a collection of documents that contain settings for certain services. There are two documents that we need here – one is named urn:sharePointPublishingRcaProperties
and the other is named http://schemas.microsoft.com/sharepoint/v3/contenttype/transformers
. The first stores the settings for any document conversions that are set up. The second contains all the converters that are excluded (doesn’t really make sense to me to do it this way but whatever). So all I had to do was add these two documents to my target content type:
1private static void AddDocumentConversionSettings(SPContentType targetCT, SPContentType sourceCT)
2{
3 // Add document conversion settings if document converisons are enabled.
4 // ParentWeb and Site will be disposed later.
5 if (targetCT.ParentWeb.Site.WebApplication.DocumentConversionsEnabled)
6 {
7 // First, handle the xml that describes what is enabled and each setting
8 string sourceDocConversionXml = sourceCT.XmlDocuments["urn:sharePointPublishingRcaProperties"];
9 if (sourceDocConversionXml != null)
10 {
11 XmlDocument sourceDocConversionXmlDoc = new XmlDocument();
12 sourceDocConversionXmlDoc.LoadXml(sourceDocConversionXml);
13
14 targetCT.XmlDocuments.Delete("urn:sharePointPublishingRcaProperties");
15 targetCT.XmlDocuments.Add(sourceDocConversionXmlDoc);
16 }
17 // Second, handle the xml that describes what is excluded (disabled).
18 sourceDocConversionXml =
19 sourceCT.XmlDocuments["http://schemas.microsoft.com/sharepoint/v3/contenttype/transformers"];
20 if (sourceDocConversionXml != null)
21 {
22 XmlDocument sourceDocConversionXmlDoc = new XmlDocument();
23 sourceDocConversionXmlDoc.LoadXml(sourceDocConversionXml);
24
25 targetCT.XmlDocuments.Delete("http://schemas.microsoft.com/sharepoint/v3/contenttype/transformers");
26 targetCT.XmlDocuments.Add(sourceDocConversionXmlDoc);
27 }
28 targetCT.Update();
29 }
30}
Setting the information rights policies was another one that took me a while – mainly because some of the core classes that Microsoft uses to do this have been obfuscated so I couldn’t disassemble them. In the end the solution once again turned out pretty simple – just took me a while to get there. To copy the policies I had to create a PolicyCatalog
object which I then use to return a PolicyCollection
object. If the policy doesn’t already exist in that collection then I export the policy from the source policy (which I got by calling the static GetPolicy()
method of the Policy
object).
The export method returns an XmlDocument
object which I then use add to my target site collection by using the static PolicyCollection.Add()
method. Once the policy exist at the site collection level then I can associate it with the content type by calling Policy.CreatePolicy
and passing in my Policy
object that I got from the source (the method name is a bit confusing as it’s not actually creating a policy – it’s just assocating a policy with your content type):
1private static void AddInformationRightsPolicies(SPContentType targetCT, SPContentType sourceCT, bool verbose)
2{
3#if MOSS
4 // Set information rights policy - must be done after the new content type is added to the collection.
5 using (Policy sourcePolicy = Policy.GetPolicy(sourceCT))
6 {
7 if (sourcePolicy != null)
8 {
9 PolicyCatalog catalog = new PolicyCatalog(targetCT.ParentWeb.Site);
10 PolicyCollection policyList = catalog.PolicyList;
11
12 Policy tempPolicy = null;
13 try
14 {
15 tempPolicy = policyList[sourcePolicy.Id];
16 if (tempPolicy == null)
17 {
18 XmlDocument exportedSourcePolicy = sourcePolicy.Export();
19 try
20 {
21 Log(string.Format("Progress: Adding policy '{0}' to content type...", sourcePolicy.Name));
22
23 PolicyCollection.Add(targetCT.ParentWeb.Site, exportedSourcePolicy.OuterXml);
24 }
25 catch (Exception ex)
26 {
27 if (ex is NullReferenceException || ex is SEHException)
28 throw;
29 // Policy already exists
30 Log("ERROR: An error occured creating the information rights policy: {0}", ex.Message);
31 }
32 }
33 Log(string.Format("Progress: Associating content type with policy '{0}'...", sourcePolicy.Name));
34 // Associate the policy with the content type.
35 Policy.CreatePolicy(targetCT, sourcePolicy);
36 }
37 finally
38 {
39 if (tempPolicy != null)
40 tempPolicy.Dispose();
41 }
42 }
43 targetCT.Update();
44 }
45#endif
46}
The last piece I had to address was the document information panel. This was hands down the most annoying to work on. I ended up grabbing a lot of code from Reflector in order to pull this off – mainly the code necessary to construct the resultant XmlDocument
object that stores all the settings as well as the code to read those settings out of the source content type. I couldn’t just copy the XML directly as the values needed to be different based on the different URLs of the target and source. The XML of interest can be retrieved using the XmlDocuments
property collection and passing in the name http://schemas.microsoft.com/office/2006/metadata/customXsn
.
Once you have this you simply have to manipulate the values or construct a new doc as I did (I won’t show that code here as it’s a bit of a mess considering it’s practically a straight copy and paste from Reflector). Beyond setting the XML settings you also have to copy the template file itself. This is actually very similar to what was needed for the document template:
1private static void AddDocumentInfoPanelToContentType(SPContentType sourceCT, SPContentType targetCT)
2{
3 XmlDocument sourceXmlDoc = null;
4 string sourceXsnLocation;
5 bool isCached;
6 bool openByDefault;
7 string scope;
8
9 // We first need to get the XML which describes the custom information panel.
10 string str = sourceCT.XmlDocuments["http://schemas.microsoft.com/office/2006/metadata/customXsn"];
11 if (!string.IsNullOrEmpty(str))
12 {
13 sourceXmlDoc = new XmlDocument();
14 sourceXmlDoc.LoadXml(str);
15 }
16 if (sourceXmlDoc != null)
17 {
18 // We found settings for a custom information panel so grab those settings for later use.
19 XmlNode node;
20 string innerText;
21 XmlNamespaceManager nsmgr = new XmlNamespaceManager(sourceXmlDoc.NameTable);
22 nsmgr.AddNamespace("cust", "http://schemas.microsoft.com/office/2006/metadata/customXsn");
23 sourceXsnLocation = sourceXmlDoc.SelectSingleNode("/cust:customXsn/cust:xsnLocation", nsmgr).InnerText;
24 node = sourceXmlDoc.SelectSingleNode("/cust:customXsn/cust:cached", nsmgr);
25 isCached = (node != null) && (node.InnerText == bool.TrueString);
26 innerText = sourceXmlDoc.SelectSingleNode("/cust:customXsn/cust:openByDefault", nsmgr).InnerText;
27 openByDefault = !string.IsNullOrEmpty(innerText) && (innerText == bool.TrueString);
28 }
29 else
30 return;
31
32 // This should never be null but just in case...
33 if (sourceXsnLocation == null)
34 return;
35
36 // Grab the source file and add it to the target resource folder.
37 SPFile sourceFile = null;
38 try
39 {
40 sourceFile = sourceCT.ResourceFolder.Files[sourceXsnLocation];
41 }
42 catch (ArgumentException)
43 {
44 }
45 if (sourceFile != null)
46 {
47 SPFile file2 = targetCT.ResourceFolder.Files.Add(targetCT.ParentWeb.Url + "/" + sourceFile.Url, sourceFile.OpenBinary(), true);
48
49 // Get the target and scope to use in the xsn for the custom information panel.
50 string targetXsnLocation = targetCT.ParentWeb.Url + "/" + file2.Url;
51 scope = targetCT.ParentWeb.Site.MakeFullUrl(targetCT.Scope);
52
53 XmlDocument targetXmlDoc = BuildCustomInformationPanelXml(targetXsnLocation, isCached, openByDefault, scope);
54 // Delete the existing doc so that we can add the new one.
55 targetCT.XmlDocuments.Delete("http://schemas.microsoft.com/office/2006/metadata/customXsn");
56 targetCT.XmlDocuments.Add(targetXmlDoc);
57 }
58 targetCT.Update();
59}
What’s interesting about many pieces of the code above is that they can easily be extracted out and used to copy other elements such as policies and site columns. I expect I’ll eventually do just that as I can see the need to have these items copied across site collections (replication is something that I don’t even want to think about as it introduces so many issues when dealing with content that is already consuming these elements – perhaps if the issue comes up I’ll look into it). The syntax of the command can be seen below:
C:\>stsadm -help gl-copycontenttypes
stsadm -o gl-copycontenttypes
Copies all Content Types from one gallery to another.
Parameters:
-sourceurl <site collection url containing the source content types>
-targeturl <site collection url where the content types will be copied to>
[-sourcename <name of an individual content type to copy - if ommitted all content types are copied if they don't already exist>
[-noworkflows]
[-nocolumns]
[-nodocconversions]
[-nodocinfopanel]
[-nopolicies]
[-nodoctemplate]
Here’s an example of how to copy all the content types from one site collection to another without copying document templates:
stsadm –o gl-copycontenttypes –sourceurl "http://intranet/operations" -targeturl "http://intranet/hr" -nodoctemplate
Update 11/26/2007: I’ve fixed a bug with copying site columns. I had omitted an Update()
call after copying the columns (it worked for me during testing because later calls were calling Update
but for certain types of content types this wasn’t happening because there was nothing to do in the later calls). I also fixed null argument issue when copying content types that are not based on documents (so no document template is present). Thanks to Chris Rivera for helping me find these.