SharePoint Automation Gary Lapointe – Founding Partner, Aptillon, Inc.

14Sep/073

Delete List Field

One of the things I've been working on recently was trying to get the site directory into a useable state post upgrade. The main issue I needed to address was that the upgrade resulted in duplicate columns for Division and Region. The duplication was because the default Site Directory template creates columns with a display name of Division (and Region) but the internal name is DivisionMulti (and RegionMulti). So when the upgrade moved the source list over it saw the names as being differing so instead of replace or merging the columns it just added new ones (this time with the internal name matching the display name).

So, I needed a simple command to allow me to delete the DivisionMulti and RegionMulti columns. The problem that I encountered was that the indexer for the Fields collection of the list (SPFieldCollection.Item[string]) wants the display name - this threw me off at first and I can see where it could potentially mess someone up really bad if they're not paying close attention to how they are using it. By passing in only the display name you will be returned back the first item that it comes across with no indication whatsoever that there may actually be another item in the list with the same name.

So my challenge here was to make the command as "safe" as I could. I accomplished this by allowing the user to pass in either the display name or the internal name (with the internal name being the safest approach). If the display name was passed in then I do a check to make sure that there's only one field with that display name - if I find more than one then I return back the matches along with the internal name so that the command can be attempted again. I thought about only allowing the internal name but figured that 90% of the time the display name will work just fine and it's easier to discover.

The other thing I needed to do was to provide some details as to why a field may not be able to be deleted. To do this I had to look into the CanBeDeleted property of the SPField object. From this I was able to see that if the field is derived from a base type or if it's sealed or if it's marked as not allowing deletion via the AllowDeletion property then it cannot be deleted. The AllowDeletion property is an easy enough one to get around so I provided a "-force" switch to allow the user to override this setting and delete the field regardless. I chose not to do anything (not allow the deletion) if it's derived from a base type or if it's sealed only because of numerous ramifications that result from attempting to delete these fields.

There's only two core parts to this code - the first involves a utility method that I created to retrieve the SPField object and the other is a small amount of code to determine whether or not the field can be deleted or not:

   1: internal static SPField GetField(string listViewUrl, string fieldName, string fieldTitle, bool useFieldName, bool useFieldTitle)
   2: {
   3: SPList list = Utilities.GetListFromViewUrl(listViewUrl);
   4:  
   5: if (list == null)
   6: {
   7:     throw new Exception("List not found.");
   8: }
   9:  
  10: SPField field = null;
  11: try
  12: {
  13:     // It's possible to have more than one field in the fields collection with the same display name.  The Fields collection
  14:     // will merely return back the first item it finds with a name matching the one specified so it's really a rather useless
  15:     // way of retrieving a field as it's extremely misleading and could lead to someone inadvertantly messing things up.
  16:     // I provide the ability to use the display name for convienence but don't rely on it for anything.
  17:     if (useFieldTitle)
  18:         field = list.Fields[fieldTitle];
  19: }
  20: catch (ArgumentException)
  21: {
  22: }
  23:  
  24: if (field != null || useFieldName)
  25: {
  26:     int count = 0;
  27:     string foundFields = string.Empty;
  28:     // If the user specified the display name we need to make sure that only one field exists matching that display name.
  29:     // If they specified the internal name then we need to loop until we find a match.
  30:     foreach (SPField temp in list.Fields)
  31:     {
  32:         if (useFieldName && temp.InternalName.ToLower() == fieldName.ToLower())
  33:         {
  34:             field = temp;
  35:             break;
  36:         }
  37:         else if (useFieldTitle && temp.Title == fieldTitle)
  38:         {
  39:             count++;
  40:             foundFields += "\t" + temp.Title + " = " + temp.InternalName + "\r\n";
  41:         }
  42:     }
  43:     if (useFieldTitle && count > 1)
  44:     {
  45:         throw new Exception("More than one field was found matching the display name specified:\r\n\r\n\tDisplay Name = Internal Name\r\n\t----------------------------\r\n" +
  46:                             foundFields +
  47:                             "\r\nUse \"-fieldinternalname\" to delete based on the internal name of the field.");
  48:     }
  49: }
  50:  
  51: if (field == null)
  52:     throw new Exception("Field not found.");
  53: return field;
  54: }
  55:  
  56: public override int Run(string command, StringDictionary keyValues, out string output)
  57: {
  58:     output = string.Empty;
  59:  
  60:     InitParameters(keyValues);
  61:     SPBinaryParameterValidator.Validate("fielddisplayname", Params["fielddisplayname"].Value, "fieldinternalname", Params["fieldinternalname"].Value);
  62:  
  63:     string url = Params["url"].Value;
  64:     string fieldTitle = Params["fielddisplayname"].Value;
  65:     string fieldName = Params["fieldinternalname"].Value;
  66:     bool useTitle = Params["fielddisplayname"].UserTypedIn;
  67:     bool useName = Params["fieldinternalname"].UserTypedIn;
  68:     bool force = Params["force"].UserTypedIn;
  69:  
  70:     SPField field = Utilities.GetField(url, fieldName, fieldTitle, useName, useTitle);
  71:  
  72:     if (!field.CanBeDeleted)
  73:     {
  74:         if (field.FromBaseType)
  75:         {
  76:             throw new Exception(
  77:                 "The field is derived from a base type and cannot be deleted.  You must delete the field from the base type.");
  78:         }
  79:         else if (field.Sealed)
  80:         {
  81:             throw new Exception("This field is sealed and cannot be deleted.");
  82:         }
  83:         else if (field.AllowDeletion.HasValue && !field.AllowDeletion.Value && !force)
  84:         {
  85:             throw new Exception(
  86:                 "Field is marked as not allowing deletion - specify \"-force\" to ignore this setting and attempt deletion regardless.");
  87:         }
  88:         else if (field.AllowDeletion.HasValue && !field.AllowDeletion.Value && force)
  89:         {
  90:             field.AllowDeletion = true;
  91:             field.Update();
  92:         }
  93:         else
  94:         {
  95:             throw new Exception("Field cannot be deleted.");
  96:         }
  97:     }
  98:  
  99:     field.Delete();
 100:  
 101:     return 1;
 102: }

The syntax of the command I created, gl-deletelistfield, can be seen below:

C:\>stsadm -help gl-deletelistfield

stsadm -o gl-deletelistfield

Deletes a field (column) from a list.

Parameters:
        -url <list view URL>
        [-fielddisplayname <field display name>] / [-fieldinternalname <field internal name>]
        [-force (used to force deletion if AllowDeletion property is false)]

Here’s an example of how to delete the DivisionMulti column from the site directory:

stsadm –o gl-deletelistfield -url "http://intranet/sitedirectory/lists/sites/allitems.aspx" -fieldinternalname DivisionMulti

29Aug/07122

Fix Publishing Pages Page Layout URL

This command was created to fix an issue with our upgraded sites as well as issues I discovered with sites that had been imported to new farms. What happened was that after the upgrade (or an import) the Page Layout URL (page.ListItem[FieldId.PageLayout]) for various publishing pages was pointing to the wrong place. This didn't prevent the page from loading as the Layout property of the page was referencing the correct PageLayout object - what failed was the editing of the page settings (so edit a page and click Page->Page Settings).

When I attempted to edit the page settings I'd get an error due to this url being incorrect (pointed to the old location). So rather than create something one off I figured I'd make this a command that I could use later as I know I'll need it any time I decide to move a site to a new site collection or farm. The command I created, gl-fixpublishingpagespagelayouturl, is detailed below (apologies for the verbose name).

The code is pretty simple - I'm just resetting the URL based on what I know it should be (I'm using the existing page layout filename and just fixing the host and site collection path). You can also pass in a known page layout url or a regular expression search and replace string to use. I suggest you test this in a virtual environment or at least by using the test parameter to see what will change before running on your entire intranet. The core code is shown below (I left out the details about how I'm looping through the sites - download the code if you'd like to see that):

   1: public static void FixPages(PublishingWeb publishingWeb, string pageName, string pageLayoutUrl, Regex searchRegex, string replaceString, bool verbose, bool fixContact, bool test)
   2: {
   3:     if (!PublishingWeb.IsPublishingWeb(publishingWeb.Web))
   4:         return;
   5:  
   6:     PublishingPageCollection pages;
   7:     int tryCount = 0;
   8:     while (true)
   9:     {
  10:         try
  11:         {
  12:             tryCount++;
  13:             pages = publishingWeb.GetPublishingPages();
  14:             break;
  15:         }
  16:         catch (InvalidPublishingWebException)
  17:         {
  18:             // The following is meant to deal with a timing issue when using this method in conjuction with other commands.  When
  19:             // used independently this should be unnecessary.
  20:             if (tryCount > 4)
  21:                 throw;
  22:             Thread.Sleep(10000);
  23:             SPWeb web = publishingWeb.Web;
  24:             SPSite site = web.Site;
  25:             string url = site.MakeFullUrl(web.ServerRelativeUrl);
  26:             site.Close();
  27:             site.Dispose();
  28:             web.Close();
  29:             web.Dispose();
  30:             publishingWeb.Close();
  31:             site = new SPSite(url);
  32:             web = site.OpenWeb(Utilities.GetServerRelUrlFromFullUrl(url));
  33:             publishingWeb = PublishingWeb.GetPublishingWeb(web);
  34:         }
  35:     }
  36:  
  37:     foreach (PublishingPage page in pages)
  38:     {
  39:         if (!(string.IsNullOrEmpty(pageName) || page.Name.ToLower() == pageName.ToLower()))
  40:             continue;
  41:  
  42:         if (verbose)
  43:         {
  44:             Log(string.Format("Begin processing {0}.", page.Url));
  45:             Log(string.Format("Current layout set to {0}.", page.ListItem[FieldId.PageLayout]));
  46:         }
  47:  
  48:         // Can't edit items that are checked out.
  49:         if (Utilities.IsCheckedOut(page.ListItem))
  50:         {
  51:             if (verbose)
  52:                 Log("Page is already checked out - skipping.");
  53:             continue;
  54:         }
  55:  
  56:         SPFieldUrlValue url;
  57:         if (string.IsNullOrEmpty(pageLayoutUrl))
  58:         {
  59:             if (searchRegex == null)
  60:             {
  61:                 if (page.ListItem[FieldId.PageLayout] == null || string.IsNullOrEmpty(page.ListItem[FieldId.PageLayout].ToString().Trim()))
  62:                 {
  63:                     if (verbose)
  64:                         Log(string.Format("Current page layout is empty - skipping.  Use the 'pagelayout' parameter to set a page layout."));
  65:  
  66:                     continue;
  67:                 }
  68:  
  69:                 // We didn't get a layout url passed in or a regular expression so try and fix the existing url
  70:                 url = new SPFieldUrlValue(page.ListItem[FieldId.PageLayout].ToString());
  71:                 if (string.IsNullOrEmpty(url.Url) ||
  72:                     url.Url.IndexOf("/_catalogs/") < 0)
  73:                 {
  74:                     if (verbose)
  75:                         Log(string.Format("Current page layout does not point to a _catalogs folder or is empty - skipping.  Use the 'pagelayout' parameter to set a page layout  Layout Url: {0}",
  76:                                 url));
  77:                     continue;
  78:                 }
  79:  
  80:  
  81:                 string newUrl = publishingWeb.Web.Site.ServerRelativeUrl.TrimEnd('/') +
  82:                               url.Url.Substring(url.Url.IndexOf("/_catalogs/"));
  83:  
  84:                 string newDesc = publishingWeb.Web.Site.MakeFullUrl(newUrl);
  85:  
  86:                 if (url.Url.ToLowerInvariant() == newUrl.ToLowerInvariant())
  87:                 {
  88:                     if (verbose)
  89:                         Log("Current layout matches new evaluated layout - skipping.");
  90:                     continue;
  91:                 }
  92:                 url.Url = newUrl;
  93:                 url.Description = newDesc;
  94:             }
  95:             else
  96:             {
  97:                 if (page.ListItem[FieldId.PageLayout] == null || string.IsNullOrEmpty(page.ListItem[FieldId.PageLayout].ToString().Trim()))
  98:                     if (verbose)
  99:                         Log(string.Format("Current page layout is empty - skipping.  Use the pagelayout parameter to set a page layout."));
 100:  
 101:                 // A regular expression was passed in so use it to fix the page layout url if we find a match.
 102:                 if (searchRegex.IsMatch((string)page.ListItem[FieldId.PageLayout]))
 103:                 {
 104:                     url = new SPFieldUrlValue(page.ListItem[FieldId.PageLayout].ToString());
 105:                     string newUrl = searchRegex.Replace((string)page.ListItem[FieldId.PageLayout], replaceString);
 106:                     if (url.ToString().ToLowerInvariant() == newUrl.ToLowerInvariant())
 107:                     {
 108:                         if (verbose)
 109:                             Log("Current layout matches new evaluated layout - skipping.");
 110:                         continue;
 111:                     }
 112:                     url = new SPFieldUrlValue(newUrl);
 113:                 }
 114:                 else
 115:                 {
 116:                     if (verbose)
 117:                         Log("Existing page layout url does not match provided regular expression - skipping.");
 118:                     continue;
 119:                 }
 120:             }
 121:         }
 122:         else
 123:         {
 124:             // The user passed in an url string so use it.
 125:             if (pageLayoutUrl.ToLowerInvariant() == (string)page.ListItem[FieldId.PageLayout])
 126:             {
 127:                 if (verbose)
 128:                     Log("Current layout matches provided layout - skipping.");
 129:                 continue;
 130:             }
 131:  
 132:             url = new SPFieldUrlValue(pageLayoutUrl);
 133:         }
 134:  
 135:         string fileName = url.Url.Substring(url.Url.LastIndexOf('/'));
 136:         // Make sure that the URLs are server relative instead of absolute.
 137:         if (url.Description.ToLowerInvariant().StartsWith("http"))
 138:             url.Description = Utilities.GetServerRelUrlFromFullUrl(url.Description) + fileName;
 139:         if (url.Url.ToLowerInvariant().StartsWith("http"))
 140:             url.Url = Utilities.GetServerRelUrlFromFullUrl(url.Url) + fileName;
 141:  
 142:         if (page.ListItem[FieldId.PageLayout] != null && url.ToString().ToLowerInvariant() == page.ListItem[FieldId.PageLayout].ToString().ToLowerInvariant())
 143:             continue; // No difference detected so move on.
 144:  
 145:         if (verbose)
 146:             Log(string.Format("Changing layout url from \"{0}\" to \"{1}\"", page.ListItem[FieldId.PageLayout], url));
 147:  
 148:  
 149:         if (fixContact)
 150:         {
 151:             SPUser contact = null;
 152:             try
 153:             {
 154:                 contact = page.Contact;
 155:             }
 156:             catch (SPException)
 157:             {
 158:             }
 159:             if (contact == null)
 160:             {
 161:                 if (verbose)
 162:                     Log(string.Format("Page contact ('{0}') does not exist - assigning current user as contact.", page.ListItem[FieldId.Contact]));
 163:                 page.Contact = publishingWeb.Web.CurrentUser;
 164:  
 165:                 if (!test)
 166:                     page.ListItem.SystemUpdate();
 167:             }
 168:         }
 169:  
 170:         if (test)
 171:             continue;
 172:  
 173:         page.CheckOut();
 174:         page.ListItem[FieldId.PageLayout] = url;
 175:         page.ListItem.UpdateOverwriteVersion();
 176:         PublishItems.Settings settings = new PublishItems.Settings();
 177:         settings.Test = test;
 178:         settings.Quiet = !verbose;
 179:         settings.LogFile = null;
 180:  
 181:         PublishItems.PublishListItem(page.ListItem, page.ListItem.ParentList, settings, "stsadm -o fixpublishingpagespagelayouturl");
 182:         //page.ListItem.File.CheckIn("Fixed URL to page layout.", SPCheckinType.MajorCheckIn);
 183:         //if (page.ListItem.ModerationInformation != null)
 184:         //    page.ListItem.File.Approve("Publishing changes to page layout.");
 185:     }
 186: }

The syntax of the command can be seen below:

C:\>stsadm -help gl-fixpublishingpagespagelayouturl

stsadm -o gl-fixpublishingpagespagelayouturl


Fixes the Page Layout URL property of publishing pages which can get messed up during an upgrade or from importing into a new farm.

Parameters:
        -url <url>
        -scope <WebApplication | Site | Web | Page>
        [-pagename <if scope is Page, the name of the page to update>]
        {[-pagelayout <url of page layout to retarget page(s) to (format: "url, desc")>] /
         [-regexsearchstring <search pattern to use for a regular expression replacement of the page layout url>]
         [-regexreplacestring <replace pattern to use for a regular expression replacement of the page layout url>]}
        [-verbose]
        [-test]

To fix all the pages on a given web application you would use the following syntax:

stsadm –o gl-fixpublishingpagespagelayouturl –url "http://intranet/" -scope webapplication

Update 2/17/2008: I've made quite a few changes to this command. Note that if you have a previous version the syntax of the command has changed a lot (content above has been updated). You can now pass in a single url parameter along with a scope parameter. Also - you can now pass in a test parameter to simulate what changes would occur. The verbose switch will tell you what it's doing. If you know you want to set a specific page to you can pass in the pagename parameter. Similarly if you want to set a specific page layout url use the pagelayout parameter or you can use the regex parameters for doing a search and replace. Note that if you pass in the pagelayout parameter you want to use the format "[url], [desc]" - for example:

stsadm -o gl-fixpublishingpagespagelayouturl -url "http://intranet" -scope site -pagelayout "http://intranet/_catalogs/masterpage/WelcomeLinks.aspx, /_catalogs/masterpage/WelcomeLinks.aspx".

23Aug/0778

Copy Content Types

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.

   1: private 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:

   1: private 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):

   1: private 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:

   1: private 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:

   1: private 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):

   1: private 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:

   1: private 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.