Propagate Content Type Changes
This is something that I put together a while ago but I'm only just now getting to the point where I can document it. I was looking for a solution to a common problem of propagating changes to content types deployed via a Feature and I came across a post by Søren Nielsen. Søren created a custom stsadm command which handles pushing down the content type changes. I didn't want to re-invent the wheel but I needed the ability to call the code in different ways and I wanted to try my hand at using the SPContentTypeUsages class so I decided to use what he created and just refactor it to meet my goals. Søren does a great job at explaining the problem and what the code is doing so I won't reiterate it here. My modified version of the code can be seen below:
1: using System;
2: using System.Collections.Generic;
3: using System.Collections.Specialized;
4: using System.Diagnostics;
5: using System.Text;
6: using Lapointe.SharePoint.STSADM.Commands.OperationHelpers;
7: using Lapointe.SharePoint.STSADM.Commands.SPValidators;
8: using Microsoft.SharePoint;
9:
10: namespace Lapointe.SharePoint.STSADM.Commands.ContentTypes
11: {
12: /// <summary>
13: /// This code was derived from Søren Nielsen's code that he provides on his blog:
14: /// http://soerennielsen.wordpress.com/2007/09/11/propagate-site-content-types-to-list-content-types/
15: /// </summary>
16: public class PropagateContentType : SPOperation
17: {
18: /// <summary>
19: /// Initializes a new instance of the <see cref="PropagateContentType"/> class.
20: /// </summary>
21: public PropagateContentType()
22: {
23: SPParamCollection parameters = new SPParamCollection();
24: parameters.Add(new SPParam("url", "url", true, null, new SPNonEmptyValidator()));
25: parameters.Add(new SPParam("contenttype", "ct", true, null, new SPNonEmptyValidator()));
26: parameters.Add(new SPParam("verbose", "v"));
27: parameters.Add(new SPParam("updatefields", "uf"));
28: parameters.Add(new SPParam("removefields", "rf"));
29:
30: StringBuilder sb = new StringBuilder();
31: sb.Append("\r\n\r\nPropagates a site scoped content type to list scoped instances of that content type.\r\n\r\nParameters:");
32: sb.Append("\r\n\t-url <site collection url>");
33: sb.Append("\r\n\t-contenttype <content type name>");
34: sb.Append("\r\n\t[-verbose]");
35: sb.Append("\r\n\t[-updatefields]");
36: sb.Append("\r\n\t[-removefields]");
37:
38: Init(parameters, sb.ToString());
39: }
40:
41: /// <summary>
42: /// Gets the help message.
43: /// </summary>
44: /// <param name="command">The command.</param>
45: /// <returns></returns>
46: public override string GetHelpMessage(string command)
47: {
48: return HelpMessage;
49: }
50:
51: /// <summary>
52: /// Runs the specified command.
53: /// </summary>
54: /// <param name="command">The command.</param>
55: /// <param name="keyValues">The key values.</param>
56: /// <param name="output">The output.</param>
57: /// <returns></returns>
58: public override int Execute(string command, StringDictionary keyValues, out string output)
59: {
60: output = string.Empty;
61:
62: using (SPSite site = new SPSite(Params["url"].Value.TrimEnd('/')))
63: {
64: Process(site, Params["contenttype"].Value, Params["verbose"].UserTypedIn, Params["updatefields"].UserTypedIn,
65: Params["removefields"].UserTypedIn);
66: }
67: return OUTPUT_SUCCESS;
68: }
69:
70: /// <summary>
71: /// Processes the content type.
72: /// </summary>
73: /// <param name="site">The site.</param>
74: /// <param name="contentTypeName">Name of the content type.</param>
75: /// <param name="verbose">if set to <c>true</c> [verbose].</param>
76: /// <param name="updateFields">if set to <c>true</c> [update fields].</param>
77: /// <param name="removeFields">if set to <c>true</c> [remove fields].</param>
78: public void Process(SPSite site, string contentTypeName, bool verbose, bool updateFields, bool removeFields)
79: {
80: Verbose = verbose;
81: try
82: {
83: Log("Pushing content type changes to lists for '" + contentTypeName + "'");
84: // get the site collection specified
85: using (SPWeb rootWeb = site.RootWeb)
86: {
87: //Get the source site content type
88: SPContentType sourceCT = rootWeb.AvailableContentTypes[contentTypeName];
89: if (sourceCT == null)
90: {
91: throw new ArgumentException("Unable to find Content Type named \"" + contentTypeName + "\"");
92: }
93:
94: IList<SPContentTypeUsage> ctUsageList = SPContentTypeUsage.GetUsages(sourceCT);
95: foreach (SPContentTypeUsage ctu in ctUsageList)
96: {
97: if (!ctu.IsUrlToList)
98: continue;
99:
100: using (SPWeb web = site.OpenWeb(ctu.Url))
101: {
102:
103: SPList list = web.GetList(ctu.Url);
104: SPContentType listCT = list.ContentTypes[ctu.Id];
105: ProcessContentType(list, sourceCT, listCT, updateFields, removeFields);
106: }
107: }
108: }
109: return;
110: }
111: catch (Exception ex)
112: {
113: Log("Unhandled error occured: " + ex.Message, EventLogEntryType.Error);
114: throw;
115: }
116: finally
117: {
118: Log("Finished pushing content type changes to lists for '" + contentTypeName + "'");
119: }
120: }
121:
122: /// <summary>
123: /// Processes the content type.
124: /// </summary>
125: /// <param name="list">The list.</param>
126: /// <param name="sourceCT">The source CT.</param>
127: /// <param name="listCT">The list CT.</param>
128: /// <param name="updateFields">if set to <c>true</c> [update fields].</param>
129: /// <param name="removeFields">if set to <c>true</c> [remove fields].</param>
130: private static void ProcessContentType(SPList list, SPContentType sourceCT, SPContentType listCT, bool updateFields, bool removeFields)
131: {
132: if (listCT == null)
133: return;
134:
135: Log("Processing content type on list:" + list);
136:
137: if (updateFields)
138: {
139: UpdateListFields(list, listCT, sourceCT);
140: }
141:
142: //Find/add the fields to add
143: foreach (SPFieldLink sourceFieldLink in sourceCT.FieldLinks)
144: {
145: if (!FieldExist(sourceCT, sourceFieldLink))
146: {
147: Log(
148: "Failed to add field "
149: + sourceFieldLink.DisplayName + " on list "
150: + list.ParentWebUrl + "/" + list.Title
151: + " field does not exist (in .Fields[]) on "
152: + "source content type", EventLogEntryType.Warning);
153: }
154: else
155: {
156: if (!FieldExist(listCT, sourceFieldLink))
157: {
158: //Perform double update, just to be safe
159: // (but slow)
160: Log("Adding field \""
161: + sourceFieldLink.DisplayName
162: + "\" to contenttype on "
163: + list.ParentWebUrl + "/" + list.Title,
164: EventLogEntryType.Information);
165: if (listCT.FieldLinks[sourceFieldLink.Id] != null)
166: {
167: listCT.FieldLinks.Delete(sourceFieldLink.Id);
168: listCT.Update();
169: }
170: listCT.FieldLinks.Add(new SPFieldLink(sourceCT.Fields[sourceFieldLink.Id]));
171: listCT.Update();
172: }
173: }
174: }
175:
176: if (removeFields)
177: {
178: //Find the fields to delete
179: //WARNING: this part of the code has not been
180: // adequately tested (though what could go wrong? …![]()
181:
182: //Copy collection to avoid modifying enumeration as we go through it
183: List<SPFieldLink> listFieldLinks = new List<SPFieldLink>();
184: foreach (SPFieldLink listFieldLink in listCT.FieldLinks)
185: {
186: listFieldLinks.Add(listFieldLink);
187: }
188:
189: foreach (SPFieldLink listFieldLink in listFieldLinks)
190: {
191: if (!FieldExist(sourceCT, listFieldLink))
192: {
193: Log("Removing field \""
194: + listFieldLink.DisplayName
195: + "\" from contenttype on :"
196: + list.ParentWebUrl + "/"
197: + list.Title, EventLogEntryType.Information);
198: listCT.FieldLinks.Delete(listFieldLink.Id);
199: listCT.Update();
200: }
201: }
202: }
203: }
204:
205: /// <summary>
206: /// Updates the fields of the list content type (listCT) with the
207: /// fields found on the source content type (courceCT).
208: /// </summary>
209: /// <param name="list">The list.</param>
210: /// <param name="listCT">The list CT.</param>
211: /// <param name="sourceCT">The source CT.</param>
212: private static void UpdateListFields(SPList list, SPContentType listCT, SPContentType sourceCT)
213: {
214: Log("Starting to update fields ", EventLogEntryType.Information);
215: foreach (SPFieldLink sourceFieldLink in sourceCT.FieldLinks)
216: {
217: //has the field changed? If not, continue.
218: if (listCT.FieldLinks[sourceFieldLink.Id] != null
219: && listCT.FieldLinks[sourceFieldLink.Id].SchemaXml
220: == sourceFieldLink.SchemaXml)
221: {
222: Log("Doing nothing to field \"" + sourceFieldLink.Name
223: + "\" from contenttype on :" + list.ParentWebUrl + "/"
224: + list.Title, EventLogEntryType.Information);
225: continue;
226: }
227: if (!FieldExist(sourceCT, sourceFieldLink))
228: {
229: Log(
230: "Doing nothing to field: " + sourceFieldLink.DisplayName
231: + " on list " + list.ParentWebUrl
232: + "/" + list.Title + " field does not exist (in .Fields[])"
233: + " on source content type", EventLogEntryType.Information);
234: continue;
235:
236: }
237:
238: if (listCT.FieldLinks[sourceFieldLink.Id] != null)
239: {
240:
241: Log("Deleting field \"" + sourceFieldLink.Name
242: + "\" from contenttype on :" + list.ParentWebUrl + "/"
243: + list.Title, EventLogEntryType.Information);
244:
245: listCT.FieldLinks.Delete(sourceFieldLink.Id);
246: listCT.Update();
247: }
248:
249: Log("Adding field \"" + sourceFieldLink.Name
250: + "\" from contenttype on :" + list.ParentWebUrl
251: + "/" + list.Title, EventLogEntryType.Information);
252:
253: listCT.FieldLinks.Add(new SPFieldLink(sourceCT.Fields[sourceFieldLink.Id]));
254: //Set displayname, not set by previous operation
255: listCT.FieldLinks[sourceFieldLink.Id].DisplayName = sourceCT.FieldLinks[sourceFieldLink.Id].DisplayName;
256: listCT.Update();
257: Log("Done updating fields ");
258: }
259: }
260:
261: /// <summary>
262: /// Fields the exist.
263: /// </summary>
264: /// <param name="contentType">Type of the content.</param>
265: /// <param name="fieldLink">The field link.</param>
266: /// <returns></returns>
267: private static bool FieldExist(SPContentType contentType, SPFieldLink fieldLink)
268: {
269: try
270: {
271: //will throw exception on missing fields
272: return contentType.Fields[fieldLink.Id] != null;
273: }
274: catch (Exception)
275: {
276: return false;
277: }
278: }
279: }
280: }
By refactoring the code slightly I'm now able to use the code via the stsadm command, which I called gl-propagatecontenttype, or I can call the Process method via my Feature Receiver by just adding a reference to the assembly - this way I can push changes to content types down to the lists they are bound to when my Feature is re-activated. Here's the syntax of the command:
C:\>stsadm -help gl-propagatecontenttype
stsadm -o gl-propagatecontenttype
Propagates a site scoped content type to list scoped instances of that content type.
Parameters:
-url <site collection url>
-contenttype <content type name>
[-verbose]
[-updatefields]
[-removefields]
|
I suggest you avoid the use of the -removefields parameter if possible - I left it there because I thought I might need it but it's usually not a good thing to do something so destructive in batch like that (just make sure you at least test the change before going to production with it).
Again - props to Søren - I just retooled his code some.
Convert a Sub-site to a Site Collection
I finally figured it out! This was supposed to be one of those very simple tasks that I should have been able to do without any custom code. Turning a sub-site (or web) into a site collection (or top level site) turned out to be the most difficult task I've yet to face with SharePoint 2007. In theory you should be able to do this using the following commands which could be put into a batch file:
REM Create a test web for exporting stsadm -o createweb -url "http://intranet/testweb" -sitetemplate "SPSTOPIC#0" REM Export the test web to the filesystem stsadm -o export -url "http://intranet/testweb" -filename "c:\testweb" -includeusersecurity -versions 4 -nofilecompression -quiet REM Create a managed path for the new top level site stsadm -o addpath -url "http://intranet/testsite" -type explicitinclusion REM Create an empty site with a default site template (note that if you don't specify a template you have to manually activate the required features) stsadm -o createsite -url "http://intranet/testsite" -owneremail "someone@example.com" -ownerlogin "domain\username" -sitetemplate "SPSTOPIC#0" REM Import the site stsadm -o import -url "http://intranet/testsite" -filename "c:\testweb" -includeusersecurity -nofilecompression -quiet
Unfortunately what you get is only a partially functional site (and in some case not functional at all). There are several errors that you are likely to encounter after running the above using the created testweb or your own existing web. The first and most obvious error is that when you load the default.aspx page of the new site you may get a File Not Found error (note that running the above as is will not give you this error). This is the result of the publishing pages PageLayout URL getting messed up (effectively still pointing to an old value). I addressed this specific issue with a separate command (http://blog.falchionconsulting.com/index.php/2007/08/fix-publishing-pages-page-layout-url/) and the I've encapsulated that functionality into the new commands I've created which are detailed below.
The next error you're likely to see is on the Area Template Settings page (Site Settings -> Page layouts and site templates). The specific error is "Data at the root level is invalid. Line 1, position 1".
Anyone who's done a lot of XML work should recognize this error as an XML parsing error. This error occurs if the web you imported from was set to inherit it's page layouts from it's parent. When a web is setup this way there's a property called "__PageLayouts" which gets set to "__inherit".
For a top level site collection this value should always be either an empty string (all page layouts are available) or XML describing which layouts are available. The import operation does not consider this and leaves the value as is thus resulting in the XML error when attempting to parse "__inherit" as XML. The fix for this is simple enough - change the value to an empty string. Unfortunately that's not all we have to do. Fixing the above error results in the page loading without errors, however, the page layouts section does not load. There's still several issues that need to be resolved. If you now go and view the master gallery (Site Settings -> Master pages and page layouts) you should see all the default page layouts. If you have any custom page layouts those won't exist and will cause problems.
Also, if you attempt to edit a file you'll notice that even though we used a publishing template it doesn't prompt you to check the file out. What's more is that once you view the form for a layout you should see that only the core fields are present (no Content Type, Associated Content Type, Variations, etc.).
There are several things that need to be fixed here - first we need to activate all the features that would otherwise be activated on a new site collection (need this so that we can get the publishing workflow options enabled). Second we need to reset all the properties for the gallery to match that of our source gallery (namely we need to allow management of content types).
Third we have to change the ContentType field from being a Text field to being a Choice field (more about this in a minute). Fourth we need to re-associate each file as a Page Layout file by setting all the necessary properties (Content Type, Associated Content Type, etc.). In regards to changing the ContentType field this is the one that caused me the most headache to figure out. For some reason during the import of the site this field gets a bit messed up (note that I'm not referring to the ContentType field that is linked in from the Page content type which is associated with the library but rather another field that is part of the gallery definition itself). The field should be a Choice field with options such as "Page Layout", "Publishing Mater Page", "Master Page", and "Folder". However, during the import the field is converted to a Text field - don't ask me why.
The result is that when you query for the list of available page layouts the unmanaged code that Microsoft uses to do the actual query chokes because it can't find a matching value in the list so it produces an invalid query which will always return no results back. To fix this I copy the source master page gallery on top of the target gallery using the content deployment API. I also found that (with SP2) the PublishingResources feature seemed to correct the issue (at least in the tests that I ran).
UPDATE 9/4/2007: I just discovered that another issue is related to the global navigation. If you view the navigation via the browser it will look as though everything is just peachy but if you attempt to manipulate the navigation programmatically you'll find that the PublishingWeb's GlobalNavigationNodes property is empty (no items are present). This is because the global navigation is stored at the site collection level so when you import the web it does not take the global navigation with it, just the current. The fix is simple enough - just loop through the current navigation collection and copy it to the global navigation collection. This will help to allow other programmatic manipulations of the global navigation to succeed.
UPDATE 7/6/2009: I am now calling the code that I created to copy content types from one site collection to another. This solves issues that can occur if a site collection content type was not created via a Feature. I've also added additional logging to better show what is happening.
So, to summarize the things that need to be repaired after importing into your empty site collection:
- Activate any features that are needed by the site
- Set the master page gallery settings
- Enable content type management
- Set your publishing options
- Fix the Content Type field so that it's a Choice field type by copying the source master page gallery
- Copy missing content types from the source site collection.
- Fix the Page Layouts and Site Templates
- Set the "__PageLayouts" property to an empty string (can then be set to something else using SetAvailablePageLayouts() but first needs to be set to an empty string as SetAvailablePageLayouts() will not work until it's fixed)
- Copy any missing page layouts from the source
- Set all appropriate properties on each page layout
- Fix all the publishing pages PageLayout property to have the correct URL
- UPDATE 9/4/2007: Update the global navigation
Some of the above can be done via the browser but most of it requires programmatic changes. In order to solve all these problems I've created two custom stsadm commands - the first will take a site make all the repairs identified above (so it assumes you've already imported the site).
The second basically just abstracts the whole process of exporting a web, creating a site, importing into the site, and then repairing the site (this way the entire process can be done with just one command). The commands I created are detailed below (forgive the verbosity of the names - I had trouble coming up with something shorter).
1. gl-repairsitecollectionimportedfromsubsite
The code is fairly well documented so rather than discuss it all (there's a lot of it) I've linked it to this post here. The syntax of the command can be seen below:
C:\>stsadm -help gl-repairsitecollectionimportedfromsubsite
stsadm -o gl-repairsitecollectionimportedfromsubsite
Repairs a site collection that has been imported from an exported sub-site. Note that the sourceurl can be the actual source site or any site collection that can be used as a model for the target.
Parameters:
-sourceurl <source location of the existing sub-site or model site collection>
-targeturl <target location for the new site collection>
The following table summarizes the command and its various parameters:
| Command Name | Availability | Build Date |
|---|---|---|
| gl-repairsitecollectionimportedfromsubsite | WSS 3, MOSS 2007 | Released: 9/4/2007
Updated: 7/6/2009 |
| Parameter Name | Short Form | Required | Description | Example Usage |
|---|---|---|---|---|
| sourceurl | source | Yes | The URL to the source sub-site to convert. | -sourceurl http://portal/subsite
-source http://portal/subsite |
| targeturl | target | Yes | The URL of the new site collection to create. | -targeturl http://portal/sites/site
-target http://portal/sites/site |
Here’s an example of how to repair the site created using the batch file above:
stsadm –o gl-repairsitecollectionimportedfromsubsite –sourceurl "http://intranet/testweb/" -targeturl "http://intranet/testsite/"
2. gl-convertsubsitetositecollection
As stated above, this command is just an abstraction of other commands - it simply calls out to stsadm to do export the site (note that you can provide a previously exported site file/folder), create the managed path, create the empty site, import the site, and finally repair the imported site. As there's nothing spectacular going on here I didn't bother culling the code out in this post (download the project if you're interested in the details). The syntax of the command can be seen below:
C:\>stsadm -help gl-convertsubsitetositecollection stsadm -o gl-convertsubsitetositecollection Converts a sub-site to a top level site collection via a managed path. Parameters: -sourceurl <source location of the existing sub-site or model site collection> -targeturl <target location for the new site collection> -owneremail <someone@example.com> [-createmanagedpath] [-haltonwarning] [-haltonfatalerror] [-includeusersecurity] [-suppressafterevents (disable the firing of "After" events when creating or modifying list items)] [-exportedfile <filename of exported site if previously exported>] [-nofilecompression] [-ownerlogin <DOMAIN\name>] [-ownername <display name>] [-secondaryemail <someone@example.com>] [-secondarylogin <DOMAIN\name>] [-secondaryname <display name>] [-lcid <language>] [-title <site title>] [-description <site description>] [-hostheaderwebapplicationurl <web application url>] [-quota <quota template>] [-deletesource] [-createsiteinnewdb] [-createsiteindb] [-databaseuser <database username>] [-databasepassword <database password>] [-databaseserver <database server name>] [-databasename <database name>] [-verbose] |
The following table summarizes the command and its various parameters:
| Command Name | Availability | Build Date |
|---|---|---|
| gl-convertsubsitetositecollection | WSS 3, MOSS 2007 | Released: 9/4/2007
Updated: 7/6/2009 |
| Parameter Name | Short Form | Required | Description | Example Usage |
|---|---|---|---|---|
| sourceurl | source | Yes | The URL to the source sub-site to convert. | -sourceurl http://portal/subsite
-source http://portal/subsite |
| targeturl | target | Yes | The URL of the new site collection to create. | -targeturl http://portal/sites/site
-target http://portal/sites/site |
| owneremail | oe | Yes |
The site owner's e-mail address. Must be valid e-mail address, in the form someone@example.com. |
-owneremail someone@example.com
-oe someone@example.com |
| createmanagedpath | createpath | No | Create a new managed path for the site collection. | -createmanagedpath
-createpath |
| haltonwarning | warning | No | Stop execution of the command if a warning event occurs during the export or import process. | -haltonwarning
-warning |
| haltonfatalerror | error | No | Stop execution of the command if a fatal error occurs during the export or import process. | -haltonfatalerror
-error |
| exportedfile | file | No | Use a previously exported site (created using stsadm's export command). | -exportedfile c:\exportdata\site
-file c:\exportdata\site |
| nofilecompression | No | Do not compress the site when exporting (or if previously exported use an uncompressed file for the import). | -nofilecompression | |
| ownerlogin | ol |
If your farm does not have Active Directory account creation mode enabled, then this parameter is required. This parameter should not be provided if your farm has Active Directory account creation mode enabled, as Microsoft Office SharePoint Server 2007 will automatically create a site collection owner account in Active Directory based on the owner e-mail address. |
The site owner's user account. Must be a valid Windows user name, and must be qualified with a domain name, for example, domain\name |
-ownerlogin domain\name
-ol domain\name |
| ownername | on | No |
The site owner's display name. |
-ownername "Gary Lapointe"
-on "Gary Lapointe" |
| secondaryemail | se | No |
The secondary site owner's e-mail address. Must be valid e-mail address, in the form someone@example.com. |
-secondaryemail someone@example.com
-se someone@example.com |
| secondarylogin | sl |
If your farm does not have Active Directory account creation mode enabled, then this parameter is required. This parameter should not be provided if your farm has Active Directory account creation mode enabled, as Microsoft Office SharePoint Server 2007 will automatically create a site collection owner account in Active Directory based on the owner e-mail address. |
The secondary site owner's user account. Must be a valid Windows user name, and must be qualified with a domain name, for example, domain\name |
-secondarylogin domain\name
-sl domain\login |
| secondaryname | sn | No |
The secondary site owner's display name. |
-secondaryname "Pam Lapointe"
-sn "Pam Lapointe" |
| lcid | No |
A valid locale ID, such as "1033" for English. You must specify this parameter when using a non-English template. |
-lcid 1033 | |
| title | t | No |
The title of the new site collection (this value will be overwritten when the site is imported - it is available only to help in situations in which the import fails). |
-title "New Site" |
| description | desc | No |
Description of the site collection (this value will be overwritten when the site is imported - it is available only to help in situations in which the import fails). |
-description "New Site Description"
-desc "New Site Description" |
| hostheaderwebapplicationurl | hhurl | No |
A valid URL assigned to the Web application by using Alternate Access Mapping (AAM), such as "http://server_name". When the hostheaderwebapplicationurl parameter is present, the value of the url parameter is the URL of the host-named site collection and value of the hostheaderwebapplicationurl parameter is the URL of the Web application that will hold the host-named site collection. |
-hostheaderwebapplicationurl http://newsite
-hhurl http://newsite |
| quota | No |
The quota template to apply to sites created on the virtual server. |
-quota Portal | |
| deletesource | No | Delete the source site after conversion (only recommended if significant testing has occurred). | -deletesource | |
| createsiteinnewdb | newdb | No | Create the site collection in a content database. | -createsiteinnewdb
-newdb |
| createsiteindb | db | No | Create the site collection in an existing content database. | -createsiteindb
-db |
| databaseserver | ds | No | The database server containing the specified content database. If not specified then the default database server is used. | -databaseserver spsql1
-ds spsql1 |
| databaseuser | du | No |
The administrator user name for the SQL Server database. |
-databaseuser domain\user
-du domain\user |
| databasepassword | dp | No |
The password that corresponds to the administrator user name for the SQL Server database. |
-databasepassword password
-dp password |
| databasename | dn | Yes if createsiteinnewdb or createsiteindb is specified. |
The name of the content database to put the site collection in (will be created if createsiteinnewdb is specified). |
-databasename SharePoint_Content1
-db SharePoint_Content1 |
| suppressafterevents | sae | No | Disable the firing of After events when creating or modifying files or list items during the import. | -suppressafterevents
-sae |
| verbose | v | No | Displays logging information when executing. | -verbose
-v |
Here's an example of how to do all that the batch file above is doing (minus the creation of the testweb) as well as the repair operation all with one command:
One area of improvement may be to pull the owner and secondary owner information from the source site collection so that this information does not have to be provided - maybe I'll do that if I feel I have the time or if people express enough interest. Note that you can specify a title and description but they'll be overwritten during the import - I only included them so that if the import fails and you're left with an incomplete site you'll at least have a name for it if you should forget to delete it and stumble upon it a year later.stsadm –o gl-convertsubsitetositecollection –sourceurl "http://intranet/testweb/" -targeturl "http://intranet/testsite/" -createmanagedpath -nofilecompression -owneremail "someone@example.com" -ownerlogin "domain\user" -deletesource
Figuring out how to solve all the issues surrounding converting a web to a site collection was a real pain the a$$ so any feedback that people have on this would be greatly appreciated - hopefully if there are others out there that have stumbled on this then they'll benefit from it as well. Keep in mind also that though I think I've solved all the errors related to the conversion it's possible that different implementations may have additional errors that I have not seen - if that's the case please let me know (especially if you've solved the problems) so that I can share with others.
Update 9/21/2007: I've fixed a couple minor bugs that pop up when converting a non-publishing site. I've also enhanced the command to take advantage of another new command I created: gl-updatev2tov3upgradeareaurlmappings (updates the url mapping of V2 bucket webs to V3 webs thereby reflecting the change of url as a result of the move so if a user tries to hit the V2 url it will redirect to the new and updated V3 url).
Update 10/2/2007: I've enhanced the command to take advantage of another new command I created: gl-retargetcontentquerywebpart (fixes Grouped Listings web parts that remained pointed at the old list rather than the newly imported list).
Update 10/12/2007: I've removed the retainobjectidentity parameter. If you attempt to use this parameter you will receive a syntax error. Turns out that retaining the object identity when going from a sub-site to a site collection just created a nightmare. However, because I still had to handle these web parts that were broken I decided to enhance the repair routines to manually retarget the DataFormWebPart and ContentByQueryWebPart web parts. So if a matching list can be found on the source then any of these web parts on your pages should be fixed so that you don't have to manually fix them (the gl-repairsitecollectionimportedfromsubsite command will do the same).
Update 7/6/2009: I removed the direct DB access code and added support for copying content types from the source.
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:
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.stsadm –o gl-copycontenttypes –sourceurl "http://intranet/operations" -targeturl "http://intranet/hr" -nodoctemplate