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

19Dec/080

gl-fixpagecontact Deprecated!!!

Woohoo! – I finally get to deprecate one of my commands – after creating and publishing 135 commands I discovered today that the August Cumulative Update fixes the issue that my gl-fixpagecontact command sought to address.  If you look back at the post for that command you can see that there was a bug with the product which resulted in a user not found exception being thrown when looking at the settings of a publishing page.  This happened in couple of different situations but the easiest way to trigger it was to simply delete the user that created the page (or that was assigned as the page contact or that last modified the page) from the site collection.  When you did this and then visited the settings page you would get an error similar to the following:

image

The error would vary slightly depending on whether or not the field was the “Contact”, “CreatedBy”, or “LastModifiedBy” field.  I decided to revisit this command due to a comment that was posted on the post related to it which asked if the command could be modified to handle the CreatedBy and LastModifiedBy fields (I was only handling the Contact field).  I did some digging and have not yet discovered a way to update these two fields for a document library (if anyone has a way to do this please let me know) so I decided to send an email out to Paul Andrew to see if the bug had already been addressed - I usually pay attention to what fixes went into each update but I guess I missed this because within just a few minutes I got a response from Paul stating that this was indeed fixed with the August update (http://support.microsoft.com/kb/956056 and http://support.microsoft.com/kb/956057).  So I decided to go ahead and update my development machine to confirm this and Merry Christmas it worked!  After the updates got installed the settings page loaded fine and the invalid fields were set to my system account – now I’m not sure what logic is used here to determine what user is used – I was logged in with my system account so I don’t know if it’s using the logged in user or the system account but regardless, the page loads and that’s the important part.

So I am now officially deprecating this command – I’ll keep it in with the package but I won’t be supporting it further now that I can finally tell people to just install the update!  Thank you Paul for your help and quick responses!!!

4Sep/0815

Create a Publishing Page via STSADM

I'm working on a project which is going to require the creation of about a hundred publishing pages which were going to have to be created by hand.  I really didn't want to have to sit there going through the UI to create all those pages so I threw together a new STSADM command that I could use via a script to create the pages: gl-createpublishingpage.

Creating publishing pages via code is pretty simple - you just use the PublishingWeb object's GetPublishingPages() method to return back the collection of pages and then call its Add method passing in the page name and layout.  From there it's just a matter of setting the field properties.

   1: public static void CreatePage(string url, string pageName, string title, string layoutName, Dictionary<string, string> fieldDataCollection)
   2: {
   3:     using (SPSite site = new SPSite(url))
   4:     using (SPWeb web = site.AllWebs[Utilities.GetServerRelUrlFromFullUrl(url)])
   5:     {
   6:         if (!PublishingWeb.IsPublishingWeb(web))
   7:             throw new ArgumentException("The specified web is not a publishing web.");
   8:  
   9:         PublishingWeb pubweb = PublishingWeb.GetPublishingWeb(web);
  10:         PageLayout layout = null;
  11:         string availableLayouts = string.Empty;
  12:         foreach (PageLayout lo in pubweb.GetAvailablePageLayouts())
  13:         {
  14:             availableLayouts += "\t" + lo.Name + "\r\n";
  15:             if (lo.Name.ToLowerInvariant() == layoutName.ToLowerInvariant())
  16:             {
  17:                 layout = lo;
  18:                 break;
  19:             }
  20:         }
  21:         if (layout == null)
  22:             throw new ArgumentException("The layout specified could not be found.  Available layouts are:\r\n" + availableLayouts);
  23:  
  24:         if (!pageName.ToLowerInvariant().EndsWith(".aspx"))
  25:             pageName += ".aspx";
  26:         
  27:         PublishingPage page = pubweb.GetPublishingPages().Add(pageName, layout);
  28:         page.Title = title;
  29:         SPListItem item = page.ListItem;
  30:  
  31:         foreach (string fieldName in fieldDataCollection.Keys)
  32:         {
  33:             string fieldData = fieldDataCollection[fieldName];
  34:  
  35:             try
  36:             {
  37:                 SPField field = item.Fields.GetFieldByInternalName(fieldName);
  38:  
  39:                 if (field.ReadOnlyField)
  40:                 {
  41:                     Console.WriteLine("Field '{0}' is read only and will not be updated.", field.InternalName);
  42:                     continue;
  43:                 }
  44:  
  45:                 if (field.Type == SPFieldType.Computed)
  46:                 {
  47:                     Console.WriteLine("Field '{0}' is a computed column and will not be updated.", field.InternalName);
  48:                     continue;
  49:                 }
  50:  
  51:                 if (field.Type == SPFieldType.URL)
  52:                     item[field.Id] = new SPFieldUrlValue(fieldData);
  53:                 else if (field.Type == SPFieldType.User)
  54:                     AddListItem.SetUserField(web, item, field, fieldData);
  55:                 else
  56:                     item[field.Id] = fieldData;
  57:             }
  58:             catch (ArgumentException)
  59:             {
  60:                 Console.WriteLine("WARNING: Could not set field {0} for item {1}.", fieldName, item.ID);
  61:             }
  62:         }
  63:  
  64:         page.Update();
  65:     }
  66: }

The help for the command is shown below:

C:\>stsadm -help gl-createpublishingpage

stsadm -o gl-createpublishingpage


Creates a new publishing page.

Parameters:
        -url <url to the publishing web within which to create the page>
        -name <the filename of the page to create (do not include the extension)>
        -title <the page title>
        -layout <the filename of the page layout to use>
        [-fielddata <semi-colon separated list of key value pairs: "Field1=Val1;Field2=Val2"> (use ';;' to escape semi-colons in data values)]

The following table summarizes the command and its various parameters:

Command Name Availability Build Date
gl-createpublishingpage MOSS 2007 Released: 9/4/2008

Parameter Name Short Form Required Description Example Usage
url   Yes The URL to the publishing web within which to create the page. -url http://portal
name n Yes The filename of the page to create.  It is not necessary to include the extension.  Do not use special characters. -name NewPage

-n NewPage
title t Yes The title of the page.  Wrap within quotes if includes spaces. -title "New Page"

-t "New Page"
layout l Yes The filename of the page layout to use. -layout DefaultLayout.aspx

-l DefaultLayout.aspx
fielddata fd No Key/Value pairs of metadata to apply to the new page.  Use the internal field name and separate multiple pairs with a semi-colon.  Use the following format when setting data: "Field1=Val1;Field2=Val2". -fielddata "PublishingContactEmail=user@domain.com;PublishingContactName=First Last"

-fd "PublishingContactEmail=user@domain.com;PublishingContactName=First Last"

The following is an example of how to add a new publishing page:

stsadm -o gl-createpublishingpage -url http://portal -name NewPage -title "New Page" -layout DefaultLayout.aspx

1May/0812

Fixing Invalid Page Contacts

Update 12/29/2008:  This command has been deprecated – the issues described below have been resolved with the August 2008 Cumulative Update (http://support.microsoft.com/kb/956056 and http://support.microsoft.com/kb/956057).

I've been doing a lot of migration work these days and if you've ever had to migrate content from one farm (or site collection) to another you know that all sorts of things can go wrong (many of which have been documented in one form or another on this blog).  One of the issues I've recently come across is related to the contact settings for a publishing page.  There are two ways in which this setting can become invalid and in both circumstances there is no way to fix the problem without using either SharePoint Designer or code (the invalid contact results in an error being thrown when trying to view the Page Settings and Schedules page).

The first way to end up with an invalid contact is to simply delete the contact from the list of users in the site collection.  Now, whether or not you should ever delete users is a topic for a different post but if you delete the contact that is associated with a page and then try to view the Page Settings and Schedule page you will see the following "User cannot be found" error:

Error: User cannot be found.

It's pretty frustrating that the page isn't sensitive enough to realize the contact is invalid and therefore either clear the setting or display some warning but at least let me load the page so that I can fix the error without having to crack open SPD.

The second way that this error can manifest itself is via a content migration.  For example - you have a valid contact assigned to the page in your test or staging environment and then you migrate the page to your production environment which is on a different domain and thus the page contact does not exist in that domain.  This is the scenario that I've been dealing with and because I've been moving pages in bulk I needed a better way than to use SPD every time I migrated a page - I wanted to be able to fix the issues as part of my deployment script.  The result is a new command I created called gl-fixpagecontact.

To better understand why the contact can become invalid (even in a migration scenario where your usernames are the same in both domains) it's important to understand how the information is stored.  The contact information is stored in the "Contact" (FieldId.Contact) field of the pages SPListItem object and the format is the users ID followed by ";#" (without the quotes) followed by the users display name: 20;#Test User

The ID shown is (obviously) not the SID of the user but rather the ID of the user in the site collections users list and obviously this ID will vary from one farm to another (and even from one site collection to another).  Note that if you delete a user from the site collection and then add them back in their ID will be set to the original ID so to fix the first scenario you can simply re-add the user, fix the contact for the page manually and then re-delete the user.

So what does my new command do?  It parses the data in the field (splits on ;#) and uses the display name to try and find a principle that matches the principle regardless of the ID.  If it can't find a match then it will replace the contact with either the current user or a user provided via the "-contact" parameter.  Note that you can also use this command to force all pages to have a contact set - it is not invalid to have an empty contact but your business rules may require that all pages have a contact set.  If you don't pass the "-allowempty" flag it will force the contact to be set to either the current user or a named user (via the -contact parameter).

Here's the syntax of the command:

C:\>stsadm -help gl-fixpagecontact

stsadm -o gl-fixpagecontact


Fixes the Page Contact property of a publishing page or pages if the current contact is invalid.

Parameters:
        -url <url>
        -scope <WebApplication | Site | Web>
        [-pagename <the name of the page to update>]
        [-contact <DOMAIN\name (contact user name to assign to the page - if not provided the current user is used)>]
        [-allowempty (allow empty contact values)]
        [-verbose]
        [-test]

Like many of my commands that do mass updates of data I provide a "-test" parameter so that you can simulate the execution and see what it would have changed without having it actually make changes - this is useful for just identifying your problem pages.  Here's a simple example of how you would run this command to fix all pages in a given site collection:

stsadm -o gl-fixpagecontact -url http://demo -scope site -allowempty -verbose

If you want to just fix a single page in a single web you would run the following:

stsadm -o gl-fixpagecontact -url http://demo -scope web -pagename default.aspx -verbose

8Apr/0846

Fun with Variations

I've recently done a lot of work with my current client to get SharePoint variations working and as a result I've learned that you need to be very patient and persistent and keep a full bottle of Excedrin nearby.  Though this post is not directly related to stsadm extensions the results of my efforts on this project produced a couple of new commands which I'll touch upon here and detail in a follow-up post.  Before you begin working on a multilingual site using variations I highly suggest that you read the Building Multilingual Solutions Using 2007 SharePoint Products and Technologies white paper.

imageVariations in, a nutshell, allow you to present the same pages using different views of the page.  Though variations are typically used for multilingual sites they can also be used for other things such as media specific views.  For this post I'm going to create multilingual variation with the source variation being English and a single target variation using Korean.  The first thing we need to do is make sure that the operating system is ready for our language packs to be installed.  If you're using an East Asian language, such as Korean, then you will need to prep the OS, otherwise the OS should already be capable of handling the language pack.  To install the East Asian languages on the OS go to Start -> Control Panel -> Regional and Language Options.  On the "Languages" tab select the appropriate options and click OK.  This will install the necessary language files which will require a reboot when complete.

Now that the OS is prepared you are ready to install the language packs.  The install is real straightforward so I won't detail it here.  If you don't already have a web application created to host your variation sites you'll want to do that now.  Once you've identified your target web application you are ready to create your site collection.  It's important to note that the variations publishing feature is scoped at the site collection level - this means that you cannot have a source variation in one site collection and a target variation in another.  Also, the variation features are installed with the publishing feature so you must pick a publishing template when creating your initial site collection or activate the publishing features.  For your root site collection make sure you pick the language template that your administrators are going to be able to work with as your end-users will never see the root site collection stuff.

imageTo create your variation hierarchy you must first enable variations.  This is done by going to the root of the site collection and selecting "Site Actions -> Site Settings -> Modify All Settings -> Variations".  You first need to establish the variation root site - typically you'll just put a "/" which will make the root of site collection the variation root but you can make the variation root be a sub-site of the site collection.  The other options are fairly straightforward but the "Update Target Web Parts" and "Resources" options may need some clarification.  If you choose to update web parts with changes from the source be aware that any translations or changes that you may have made in the target variations will be overwritten with that of the source.  So if you're using a content editor web part and translating that content on the target sites then those translations will be overwritten when the page is propagated.  This may or may not be what you intend to happen.

One other thing that may require some additional clarification is the Resources option.  If you leave the option set to reference existing resources then there's essentially no change to the content - it will simply references images, for example, on the source site.  The copy option, however, will copy images to the target variations.  Note that if the image in question does not exist in the variations PublishingImages library then it won't be copied.  I've not had to deal with other resource types so I can't say what the limitations are for anything other than images.

imageOnce you've established that the site collection is to use variations you now need to create your variation labels.  When you initially create your labels you are not actually creating any instances of a site - you are just telling SharePoint how you want your sites to be created.  The sites are actually created later when you choose to create the variation hierarchy.  The first label you want to create is your source label.  It's important to note that once you've established your source you cannot change it later.  If you would a custom site template to be available here make sure that the FilterCategories property of your site template is set to PublishingSiteTemplate (you can find information about how to create your own publishing site templates here).  The "Label Name" field is what will appear in your URL - so if you specify "en-us" as the label name your url for the variation will look like "http://demo/en-us/".  For the "Hierarchy Creation" option you need to specify what you want copied when the label hierarchy is created.  The other options on this screen are very straightforward - you just need to specify the language template to use and the local and a name.

For this demo I've setup a source variation with a label name of "EN", "English" as the language template, "English (United States)" as the locale, "US-English" as the name, and "Publishing Site with Workflow" as the site template.  I've also chosen to copy the publishing site and all pages during hierarchy creation.

imageOnce your source variation is setup you can set up all of your target variations.  Note that you don't have to set the target variations up right away - you can create your hierarchy now and have it just create your source variation, get it looking/working the way you want and then add target variations as you are ready to handle them.  I would recommend though that you at least create one target variation so that you can work out the various issues that you're likely to encounter (see below) before you roll out your source variation.

For the purposes of this demonstration I'm going to create one target variation label which I'll name "KO".  The language template and locale will be set to "Korean".

imageOnce you've established your labels you are ready to create your variation hierarchy.  You do this by clicking the "Create Hierarchies" link on the "Variation Labels" page.

Now that you've got your variations created you can go to the site.  Notice that if you go to the root of the site collection (in my case "http://demo/" you will be automatically redirected to the variation whose locale matches that of your browser.  If SharePoint is unable to find a locale match then it will redirect you to the source variation.  The redirect is happening because SharePoint has replaced the welcome page of the root site collection with a new page called "VariationRoot.aspx".  It's important to note that the default.aspx page is still present and if a user navigates to that page they will see it so I recommend either deleting this page or adding a redirect web part or something similar to redirect users to the VariationRoot.aspx file so that it can do its thing.

Okay, so you've got your variations set up, now what?  Now it's time to create some content (realistically though you're going to want to work on branding and custom page layouts first but as there are tons of posts on how to do this I'm going to assume you've got that covered and are ready for creating content).  Editing the page in your source variation is no different than editing any publishing page.  However, once you publish the page the changes that you have made will be automatically copied to the variations.  Note that the act of copying the page is the result of an event receiver that is hooked up to the Pages library.  The event receiver will inspect the page and determine what needs to occur and then trigger a timer job to handle the actual copying of the page from the source library to all target libraries.  This timer job uses the Deployment API to copy the pages and if you've read any of my earlier posts you know that this imagedeployment API has numerous limitations (I'll touch upon a couple of problems below).  Because a timer job is used you won't see the pages propagated immediately - it could take several seconds to several minutes (I've even see it take several hours to show up).  If you'd like the page to be copied immediately then you can trigger it by selecting the "Update Variations" option from the "Tools" menu on the page editing toolbar.  If the page doesn't already exist in a variation you can use the "Submit a Variation..." link to submit the page to all variations.  If the page already exists in all variations then the "Submit a Variation..." link will effectively just work like the Create Page action.  Note that you can also use content deployment jobs to handle the translation of content by external agencies.  The XML that results from a content deployment job is a nightmare to work with but it wouldn't take much to provide a custom interface to that could be provided to your translators to work with the content.

image Now that you've got your changes made and you've created the variation, how do you get to that other pages?  One option is to simply edit the URL but you're users aren't going to know how to do that.  Fortunately there's a built in user control that we can enable called the Variation Label Control.  The control works via a SharePoint delegate control which is added to the master page of all publishing sites.  To enable the control you simply have to un-comment the server control tag in the "12\template\controltemplates\VariationsLabelMenu.ascx" file.  Once this has been enabled by un-commenting the server control tag then a drop down menu will appear at the top of the site allowing the user to jump to matching pages in other variations. 

It's important to understand how SharePoint relates the various pages to each other because in certain situations this relationship can get corrupted.  At the root of the site collection you can find a hidden list called "Relationships List".  This list contains entries which map the relationship between the various pages.  The behavior of the Variation Label Menu control is driven by this list so if the menu is not listing all pages then most likely it is because the Relationships List became corrupted (for example, an administrator on the Korean side decided to prevent changes from being propagated by deleting the page in the Korean Pages library and recreating it with new translated content).

imageLet's take a look at this hidden list - in our demo site we'll navigate to "http://demo/Relationships%20List/AllItems.aspx".  Once here we immediately see a few items with the title set to nothing (the first thing I usually do is create a new view showing at least the following columns: GroupID, ObjectID, and Deleted.  There will always be at least one entry in this list corresponding to the variation home which we set up earlier and defined as "/".  This entry, which will be the first item in the list, will always have "F68A02C8-2DCC-4894-B67D-BBAED5A066F9" as it's GroupID and whatever was set as the home path as it's ObjectID.  ParentAreaID has been empty in every example I've ever seen and does not appear to be used.  The GroupID field is used to group related elements.  So each variation label in the hierarchy will have the same GroupID and each page will have the same GroupID as its copied page in each variation.  The ObjectID is always the server relative url of the element (so the path to the variation label or page).

It's important to understand though that this is not the only place in which we find this information.  SharePoint will also store the GroupID and a backward link to an item in this list within two hidden fields on the page: PublishingVariationGroupID and Publishing VariationRelationshipLinkFieldID, respectively.  This information is also replicated in the MetaInfo property bag.  If we run my custom gl-exportlistitem2 command we can see what these values are for the default.aspx page:

C:\>stsadm -o gl-exportlistitem2 -url http://demo/en/pages/forms/allitems.aspx -path c:\exportdata\pages_en

The following is a snippet of the Manifest.xml file which results from running the above command (note that I've pulled some elements that were not relevant):

<Items SiteUrl="http://demo" WebUrl="/en">
  <Item IsPublishingPage="True" ContentTypeId="0x010100C568DB52D9D0A14D9B2FDCC96666E9F2007948130EC3DB064584E219954237AF390064DEA0F50FC8C147B0B6EA0636C4A7D400040A230B8F679045ADE877C7A560A0E7" PageLayout="WelcomeLinks.aspx" File="c:\exportdata\pages_en\Data\default.aspx" CreatedDate="4/3/2008 4:27:48 PM" LeafName="default.aspx" FolderUrl="">
    <Fields>
      <Field Name="Title">Home</Field>
      <Field Name="PublishingVariationGroupID">41ad5bd3-a882-4d50-a2ac-db5de6eb872c</Field>
      <Field Name="PublishingVariationRelationshipLinkFieldID">http://demo/Relationships List/4_.000, /Relationships List/4_.000</Field>
      <Field Name="MetaInfo">PublishingPageContent:SW|Test
vti_parserversion:SR|12.0.0.6219
vti_charset:SR|utf-8
vti_author:SR|
vti_setuppath:SX|SiteTemplates\\BLANKINTERNET\\default.aspx
vti_cachedneedsrewrite:BR|false
PublishingPageImage:SW|
SummaryLinks:SW|&lt;div title="_schemaversion" id="_3"&gt;\r\n  &lt;div title="_view"&gt;\r\n    &lt;span title="_columns"&gt;1&lt;/span&gt;\r\n    &lt;span title="_linkstyle"&gt;&lt;/span&gt;\r\n    &lt;span title="_groupstyle"&gt;&lt;/span&gt;\r\n  &lt;/div&gt;\r\n&lt;/div&gt;
vti_modifiedby:SR|SPDEV\\spadmin
vti_cachedhastheme:BR|false
PublishingVariationGroupID:SW|41ad5bd3-a882-4d50-a2ac-db5de6eb872c
SummaryLinks2:SW|&lt;div title="_schemaversion" id="_3"&gt;\r\n  &lt;div title="_view"&gt;\r\n    &lt;span title="_columns"&gt;1&lt;/span&gt;\r\n    &lt;span title="_linkstyle"&gt;&lt;/span&gt;\r\n    &lt;span title="_groupstyle"&gt;&lt;/span&gt;\r\n  &lt;/div&gt;\r\n&lt;/div&gt;
vti_cachedcustomprops:VX|PublishingPageImage PublishingPageContent SummaryLinks PublishingVariationGroupID SummaryLinks2 ContentType PublishingVariationRelationshipLinkFieldID vti_title PublishingPageLayout
ContentTypeId:SW|0x010100C568DB52D9D0A14D9B2FDCC96666E9F2007948130EC3DB064584E219954237AF390064DEA0F50FC8C147B0B6EA0636C4A7D400040A230B8F679045ADE877C7A560A0E7
PublishingVariationRelationshipLinkFieldID:SW|http://demo/Relationships List/4_.000, /Relationships List/4_.000
vti_cachedtitle:SR|Home
vti_title:SR|Home
ContentType:SW|Welcome Page
PublishingPageLayout:SW|/_catalogs/masterpage/WelcomeLinks.aspx, /_catalogs/masterpage/WelcomeLinks.aspx
</Field>
    </Fields>
  </Item>
</Items>

Knowing that this information is stored in two places like this becomes extremely important if you are trying to troubleshoot issues with the variation propagation process.  If the data in the Relationships List does not match up with the data in the page or if the related pages do not share the same GroupID then the variation propagation will fail.


Things that can go wrong (and they will)

Now that you hopefully have at least a basic understanding of what variations are and how they work we can begin to discuss where things fall apart (and if your deployment is anything more than demoware then you most likely will run into one or all of these issues).  The following issues are what I've personally encountered:

  1. The Relationships List can get "confused" and have either missing or invalid entries
  2. The page fields linking the page to the Relationships List can become invalid
  3. Web parts must inherit from Microsoft.SharePoint.WebPartPages.WebPart or the variation propagation will fail causing the application pool to crash (there is a hotfix for this: http://support.microsoft.com/kb/948947)
  4. Your pages must have a valid contact or no contact or you will not be able to get into the page settings for the page (this is more of a content deployment problem and not a variation problem)
  5. Your pages must have a valid page layout URL or the variation propagation will fail causing the application pool to crash (among other things)
  6. Content types for pages must match
  7. The variation label menu will only work with variation pages and not system pages (such as a list view page)
  8. The variation label menu will not preserve querystring values
  9. If you're working with large sites (several thousand pages and multiple variations) expect propagation times in terms of days (I've not personally dealt with this one but in my small environment with only a few pages it takes close to an hour for the pages to propagate so it's easy to extrapolate out)
  10. Navigation structure is not preserved from one variation to another
  11. Root site pages and other content is still available if I know how to get to it
  12. Custom list definitions and site definitions will need to be localized (use resource files) - definitions reverse engineered using Solution Generator will not be localized by default
  13. Values in localized choice columns will lose localization settings on propagation
  14. Only pages in the "Pages" library will be propagated - list items will have to be handled using custom event receivers or workflows
  15. Page field values are overwritten when a page is propagated

In the following sections I will try to cover each of the above issues and what I did to work around the issue.

1,2 - Relationships List Gets "Confused" and/or Page Fields are Invalid or Missing

There are times when the items in the Relationships List can get out of sync with the pages they are supposed to correspond to or vice-a-versa (either entries could be missing or IDs can be incorrect or mismatched).  When this happens you will run into issues with the propagation of page changes to variations.  For example, you may receive errors stating that a page already exists when you go to propagate changes to a page.  The most common way for this list to get corrupted is when pages are imported from other environments.  To fix the data in the Relationships List and the page fields I created a new stsadm command called "gl-fixvariationrelationships".  The command essentially loops through all the pages in a variation and tries to find the page with the matching name in the other variations and then rebuilds the data in the Relationships List and the page fields based on its findings.  Here's an example of how to run this command:

c:\>stsadm -o gl-fixvariationrelationships -url http://portal -verbose

Update 4/14/2008: I've just finished documenting this command here.

Update 9/2/2008: Tim Dobrinski has a great tool that he's put together for fixing many issues with the relationships list (including addressing sub-sites which I'm not currently dealing with).  You can find details about the tool here: http://www.thesug.org/blogs/lsuslinky/Lists/Posts/Post.aspx?List=ee6ea231%2D5770%2D4c2d%2Da99c%2Dc7c6e5fec1a7&ID=21

3 - Web Parts Must Inherit From Microsoft.SharePoint.WebPartPages.WebPart

I consider this particular issue to be a bug with the product because it could have been easily solved.  To demonstrate this I created a simple HelloWorld web part which just output some text and inherited from System.Web.UI.WebControls.WebPart.  I added this web part to my default.aspx page and published the page.  The result is that an InvalidCastException is thrown and the application pool crashes!  You can see the error details below - note that you'll see three errors in the event log all saying essentially the same thing.

image

If I change my web part code to inherit from Microsoft.SharePoint.WebPartPages.WebPart then the variation propagation works just fine.  So for your custom web parts the solution is real easy - just inherit from the SharePoint WebPart base class.  For third party web parts (the Dundas Chart web parts are a good example of this) you will need to wrap them in a custom web part.  The interesting thing to note is that even though the propagation seemed to fail and the application pool crashed I found that in most cases the web part was in fact successfully propagated.  The reason for this is because the failure happens in the PublishingPage object's FixWebPartUrlsForVariation method.  When this method is called the page and web parts have already been propagated and now the API is simply repairing some URLs within the web parts on the page - so in many cases repairs won't be necessary so everything will work just fine.  Microsoft could avoid this bug by simply doing a type check in the FixWebPartUrlsForVariation method before doing the cast.

Note that the only web parts out-of-the-box that are affected by the FixWebPartUrlsForVariation method are those that inherit from IWebPartVariationUpdate which at present only include the ContentByQueryWebPart and the TableOfContentsWebPart.

Update 4/21/2008: There is a hotfix for this issue - http://support.microsoft.com/kb/948947 - thanks to djeeg for adding the comment about this!

4 - Pages Must Have a Valid Contact

There are many ways in which the contact for a page can become invalid.  The most common is when a contact is deleted from the site collection.  Another way is when a page is imported from one environment to another.  Having an invalid contact "shouldn't" prevent the page variation propagation from occurring, so as far as variations are concerned this shouldn't be an issue.  However, many shops will choose to have a separate authoring environment and will import the pages into their public environment.  In this scenario you are very likely to end up with invalid contacts on your pages.  The issue comes when you choose to view the "Page Settings and Schedules" page.  If your page has an invalid contact associated with it you will receive a user not found error.  Of course the real problem here is that this is the page that allows you to fix the contact so your essentially stuck with no way of fixing the contact.  This seems like an obvious bug - the settings page should be able to handle the case of a contact being deleted so that you can assign a valid contact.

To work around these issues I've created another new custom stsadm command which I called "gl-fixpagecontact".  This command will identify all pages with an invalid contact and will either attempt to locate a valid contact, assign the current user as the contact, or assign a passed in user as the contact.  The contact field for the page is stored as a string in the format of "id;#name".  Often times when a page is imported from another environment the name will match but the ID will be different.  In this case the command will attempt to locate a principle with a matching name.

Here's an example of how to run this command:

C:\>stsadm -o gl-fixpagecontact -scope site -url http://demo -verbose

5 - Pages Must Have a Valid Page Layout URL

This particular issue can come up in a variety of situations and isn't necessarily limited to variations.  There are two ways in which this problem typically manifests itself - the first is when a page is migrated from another site (the entire site may have also been migrated) and the second is when you assign a page layout that exists in your source variation but not in your target variation.  To demonstrate the problem I created a new page layout which was just a copy of the WelcomeLinks.aspx page and stored it in the master page gallery located at http://demo/en/_catalogs/masterpage/forms/allitems.aspx - I then changed the /en/default.aspx page to use this new page layout.  After approving the page I clicked the "Update Variations" menu item to propagate my page.  The result is that 3 error events were logged in the event log and the application pool crashed!

To resolve these issues make sure that you either use only site collection level page layouts or that for every page layout you have in your variation sites you make sure that there is a layout with the same name in every other variation site (the contents can be different - they just need to be named the same).  In some cases (particularly when you import the content from another source) you won't be able to reset the page layout using the browser (going to the "Page Settings and Schedules" page will throw a FileNotFoundException).  To help fix this problem you can use a command I created a while ago called "gl-fixpublishingpagespagelayouturl".  This command has a variety of different options but here's a command of how to explicitly set the layout for a specific page:

C:\>stsadm -o gl-fixpublishingpagespagelayouturl -url http://demo/en -pagename default.aspx -pagelayout "http://demo/_catalogs/masterpage/WelcomeLinks.aspx, http://demo/_catalogs/masterpage/WelcomeLinks.aspx" -verbose -scope page

6 - Page Content Types Must Match

I've not experienced this particular issue but I thought it was worth mentioning here for completeness.  Jeremy Jameson gives a great explanation of the problem in his 3 part posts "Dumping MOSS 2007 Variations" so I won't bother re-iterating it here (the gist of it though - use SP1 with custom site definitions if you need to use page content types).

7 - Variations Label Menu Only Works with Publishing Pages

This is rather minor limitation but its one you should be aware of before you decided to use it.  If you don't allow any access to system or list pages (/_layouts/viewlsts.aspx or /Documents/Forms/AllItems.aspx for example) then you don't need to worry about this.  If you do and you have matching pages/lists throughout all your variations then you'll need to create a custom data source or your own menu if you want the ability to jump between variations.

8 - Variation Label Menu Does Not Preserve Querystring Values

I consider this to be a minor issue as well.  If you have a page which, for example, uses a page field filter which takes in values from the querystring and you then use the variation label menu to jump between variations those querystring values will not be preserved.  The reason I consider this minor is because I personally consider this menu to be nothing but a waste.  If you're asked to use it you really should ask yourself why?  Is it for the end users so that they can read the same content in different languages (who does that?)? Or is it for your testers so that they can jump between the various variations easily?  I think you'll find that this is a feature that, under most circumstances, will never be used by your end users - so why deploy it?

9 - Long Running Propagation Jobs with Large Sites

This is another one that I don't have personal experience with but Jeremy Jameson explains the problem real well in his previously mentioned post "Dumping MOSS 2007 Variations".

10 - Navigation Structure Doesn't Propagate Between Variations

This isn't actually a bug - it's by design and I completely understand the reasoning behind it.  You may have very different navigation needs between different languages and there's no easy way for the Microsoft to anticipate all needs.  However, most will want the structure of their global and current navigation to match across all variations.  This just another one of those things that you need to anticipate and plan for as you plan out your multilingual sites.  One thing that I've used to help keep things in sync is my "gl-enumnavigation" command to export the navigation from the source variation and then let the translators translate the resultant XML and then use my "gl-setnavigationnodes" command to set the navigation for the target variations (at least for an initial site propagation - after that I've just made the changes manually).

11 - Root default.aspx and Other Root Pages Are Still Available

One thing I found that needs some clarification in the Building Multilingual Solutions Using 2007 SharePoint Products and Technologies white paper is its description of the VariationRoot.aspx file:

"After a variation hierarchy is created, the Variations feature replaces the default page of the variation’s home site with a special page called VariationRoot.aspx"

To be more precise - the feature changes the Welcome Page settings so that VariationRoot.aspx is now the welcome page of the site - so if I type in "http://demo/" I'll be taken to the variation that's appropriate based on my browser settings.  However, if I type in "http://demo/pages/default.aspx" I'll be taken to the root site collections default.aspx page, not the variation site.  The same goes for any other content, lists, and sites that exist at the root site (assuming I have access).  The solution here is pretty simple - if you don't want them getting to it either delete it (if it's not needed) or lock it down so they can't get access to it.  In the case of the default.aspx page you may want to keep it there and keep the access open and then just add a redirection web part or some simple javascript to handle redirecting to the VariationRoot.aspx page.

12 - Custom List Definitions Should be Localized

If you're creating custom list definitions then you should plan on making sure those list definitions are localized - set display names, choice values, query parameters, etc., to resource strings.  One thing to be aware of is that if you use Solution Generator (part of the Visual Studio Extensions for WSS) to reverse engineer your lists you'll have to set all the fields to use localized strings which could add some time to your development/QA cycle.

13 - Localized Choice Columns Fail on Propagation

If you use any localized list definitions, as mentioned above, be aware that a side effect is that choice column values will be stored using the actual values, not the resource strings.  Sjoert Ebben discusses this issue in his post "5 reasons why you should not use variations".  Sjoert recommends a couple of solutions, one of which is to create a custom field type which will store the resource string and not the actual value.

14 - Only Pages in the Pages Library will be Propagated to Variations

To some this may seem like a pretty obvious point but I found that many I spoke to about using variations initially were rather surprised that they couldn't enable this functionality for any list.  In order to get list values (such as announcements and such) propagated between variations I enabled moderation on the desired lists and created a custom event receiver which, upon approval, copies the list item to the lists with the same name in all variations.  You could also use a custom workflow to do the same - it really just depends on how complex your needs are.  You'll find that handling modifications to data will require careful consideration.  I'm hoping to provide a sample of my event receiver in a follow-up post so be on the lookout for that.

15 - Page Field Values Are Overwritten Upon Propagation

So here's the dilemma - best practice with publishing pages is to use page fields for all content because web part changes are not stored with the history of the page - unfortunately though, when you are using a page field and you approve your page in the source variation the page will propagate to all target variations and all page field values will be overwritten.  One solution to this is to add a custom page field where the editor can indicate whether the content change is a minor modification or a new version and then create a custom workflow which will delete the draft in the variation if the page is a minor modification.  Some solutions will decide to simply put all content in web parts and just turn off the web part propagation - I still don't recommend this as you will not be able to roll back to a previous version of the page (again, web part changes are stored separately from page data and cannot be rolled back!).

Conclusion

Variations can be a very cool feature that, for simple sites, could give you exactly what you need to get you up and running with a multilingual site.  The key with variations, as with just about any SharePoint feature, is careful planning.  Make sure you give yourself plenty of time to prototype your solution and, most importantly, get your translators involved early on as you will find that many things will work just fine when everything is in English but as soon as you start translating your content and resource files you will encounter issues.  Sometimes these issues can be addressed with simple planning and change control policies but in most cases you'll be looking at custom code and/or workflows and/or site and/or list definitions.

So, if you're going to be working with variations make sure you've got lots of coffee, plenty of headache drugs, a good punching bag, and lots of time to switch to a backup plan ;)

28Jan/084

Set the Welcome Page for a Web

When I did the gradual upgrade I found that several sites had the welcome page set to UpgLandingPgRedir.aspx when it should have been set to default.aspx. I didn't have time to figure out why it was doing this but the fix was easy enough - I just had to change the welcome page back. To do this I created a new command: gl-sitewelcomepage. The code to set this is really simple - basically just get a PublishingWeb instance and set the DefaultPage property to a valid SPFile object:

   1: string url = Params["site"].Value.TrimEnd('/');
   2: string page = SPHttpUtility.UrlPathDecode(Params["welcomepage"].Value, true);
   3:  
   4: using (SPSite site = new SPSite(url))
   5: using (SPWeb web = site.AllWebs[Utilities.GetServerRelUrlFromFullUrl(url)])
   6: {
   7:  if (!PublishingWeb.IsPublishingWeb(web))
   8:   throw new SPException("The specified site is not a publishing web.");
   9:  
  10:  PublishingWeb pubWeb = PublishingWeb.GetPublishingWeb(web);
  11:  
  12:  bool flag = false;
  13:  SPFile file = null;
  14:  try
  15:  {
  16:   file = pubWeb.Web.GetFile(page);
  17:   flag = file.Exists;
  18:  }
  19:  catch (SPException)
  20:  {
  21:   flag = false;
  22:  }
  23:  catch (FileNotFoundException)
  24:  {
  25:   flag = false;
  26:  }
  27:  catch (ArgumentException)
  28:  {
  29:   flag = false;
  30:  }
  31:  
  32:  if (!flag || file == null)
  33:   throw new SPException("The specified welcome page could not be found.");
  34:  
  35:  
  36:  PublishingWeb currentPublishingWeb = pubWeb;
  37:  currentPublishingWeb.DefaultPage = file;
  38:  currentPublishingWeb.Update();
  39: }

The syntax of the command can be seen below:

C:\>stsadm -help gl-sitewelcomepage

stsadm -o gl-sitewelcomepage

Sets the page to be used as the welcome page for the site.

Parameters:
        -site <url of the site to update>
        -welcomepage <full url to the page to use as the welcome page>
Here's an example of how to set the welcome page for a site:
stsadm -o gl-sitewelcomepage -site "http://intranet/sites/teamsite1" -welcomepage "http://intranet/sites/teamsite1/pages/default.aspx"

28Jan/080

List All Welcome Pages

After I had created the gl-sitewelcomepage command I needed a quick way to find all the pages that were pointing to the wrong place. To do this I hacked out this command which essentially just loops through a farm, web application, site collection or web and displays what the current welcome page is set to for each web. The command is called gl-enumwelcomepages. Like the gl-sitewelcomepage command this command is really simple - most of the code is focused on looping through the various objects to get to the PublishingWeb object:

   1: /// <summary>
   2: /// Runs the specified command.
   3: /// </summary>
   4: /// <param name="command">The command.</param>
   5: /// <param name="keyValues">The key values.</param>
   6: /// <param name="output">The output.</param>
   7: /// <returns></returns>
   8: public override int Run(string command, StringDictionary keyValues, out string output)
   9: {
  10:  output = string.Empty;
  11:  
  12:  InitParameters(keyValues);
  13:  
  14:  string url = Params["url"].Value;
  15:  if (url != null)
  16:   url = url.TrimEnd('/');
  17:  
  18:  string scope = Params["scope"].Value.ToLowerInvariant();
  19:  
  20:  if (scope == "farm")
  21:  {
  22:   foreach (SPService svc in SPFarm.Local.Services)
  23:   {
  24:    if (!(svc is SPWebService))
  25:     continue;
  26:  
  27:    foreach (SPWebApplication webApp in ((SPWebService)svc).WebApplications)
  28:    {
  29:     DisplayWelcomePageUrl(webApp);
  30:    }
  31:   }
  32:  }
  33:  else if (scope == "webapplication")
  34:  {
  35:   SPWebApplication webApp = SPWebApplication.Lookup(new Uri(url));
  36:   DisplayWelcomePageUrl(webApp);
  37:  }
  38:  else if (scope == "site")
  39:  {
  40:   using (SPSite site = new SPSite(url))
  41:   {
  42:    DisplayWelcomePageUrl(site);
  43:   }
  44:  }
  45:  else if (scope == "web")
  46:  {
  47:   using (SPSite site = new SPSite(url))
  48:   using (SPWeb web = site.AllWebs[Utilities.GetServerRelUrlFromFullUrl(url)])
  49:   {
  50:    DisplayWelcomePageUrl(site, web, true);
  51:   }
  52:  }
  53:  
  54:  return 1;
  55: }
  56:  
  57:  
  58: /// <summary>
  59: /// Validates the specified key values.
  60: /// </summary>
  61: /// <param name="keyValues">The key values.</param>
  62: public override void Validate(StringDictionary keyValues)
  63: {
  64:  if (Params["scope"].UserTypedIn)
  65:  {
  66:   if (Params["scope"].Value.ToLowerInvariant() == "farm" && Params["url"].UserTypedIn)
  67:    throw new SPSyntaxException("The url parameter is not compatible with a scope of Farm.");
  68:   if (Params["scope"].Value.ToLowerInvariant() != "farm" && !Params["url"].UserTypedIn)
  69:    throw new SPSyntaxException("The url parameter is required if the scope is not Farm.");
  70:  }
  71:  base.Validate(keyValues);
  72: }        
  73:  
  74: #endregion
  75:  
  76:  
  77: /// <summary>
  78: /// Displays the welcome page URL.
  79: /// </summary>
  80: /// <param name="webApp">The web app.</param>
  81: private static void DisplayWelcomePageUrl(SPWebApplication webApp)
  82: {
  83:  foreach (SPSite site in webApp.Sites)
  84:  {
  85:   try
  86:   {
  87:    DisplayWelcomePageUrl(site);
  88:   }
  89:   finally
  90:   {
  91:    site.Dispose();
  92:   }
  93:  }
  94: }
  95:  
  96: /// <summary>
  97: /// Displays the welcome page URL.
  98: /// </summary>
  99: /// <param name="site">The site.</param>
 100: private static void DisplayWelcomePageUrl(SPSite site)
 101: {
 102:  foreach (SPWeb web in site.AllWebs)
 103:  {
 104:   try
 105:   {
 106:    DisplayWelcomePageUrl(site, web, false);
 107:   }
 108:   finally
 109:   {
 110:    web.Dispose();
 111:   }
 112:  }
 113: }
 114:  
 115: /// <summary>
 116: /// Displays the welcome page URL.
 117: /// </summary>
 118: /// <param name="site">The site.</param>
 119: /// <param name="web">The web.</param>
 120: /// <param name="recurseSubWebs">if set to <c>true</c> [recurse sub webs].</param>
 121: internal static void DisplayWelcomePageUrl(SPSite site, SPWeb web, bool recurseSubWebs)
 122: {
 123:  if (recurseSubWebs)
 124:  {
 125:   foreach (SPWeb subweb in web.Webs)
 126:   {
 127:    DisplayWelcomePageUrl(site, subweb, recurseSubWebs);
 128:   }
 129:  }
 130:  
 131:  if (!PublishingWeb.IsPublishingWeb(web))
 132:   return;
 133:  
 134:  PublishingWeb pubWeb = PublishingWeb.GetPublishingWeb(web);
 135:  
 136:  Console.WriteLine(site.MakeFullUrl(web.ServerRelativeUrl) + " = " + pubWeb.DefaultPage);
 137: }

The syntax of the command can be seen below:

C:\>stsadm -help gl-enumwelcomepages

stsadm -o gl-enumwelcomepages

Lists all the welcome page(s) for a farm, web application, site collection, or web.

Parameters:
        -url <url to search>
        -scope <Farm | WebApplication | Site | Web>
Here's an example of how to list all the welcome pages in a web application:
stsadm -o gl-enumwelcomepages -url "http://intranet" -scope webapplication

3Oct/0726

Publish Items

I wasn't planning on creating this command but then I ran my gl-replacefieldvalues command and forgot to pass the "-publish" switch in so now I was stuck with all these list items that needed to be published and/or approved. When this happened I thought, no problem - Andrew Connell has a PublishAllItems command so I'll just download his stuff and use that rather than create my own. Unfortunately however, Andrew's command didn't take all scenarios into account and thus I was left with the task of creating a new command to do what Andrew's attempted.

For those that are using Andrew's command without issue I decided to name mine "gl-publishitems" so as to not step on his command thus allowing both to be installed. The main problem I had with Andrew's command was that it didn't take into account items that needed approval but which did not have an SPFile object associated with it. There were also issues where he was calling SPListItem.Update() after a checkin which would throw an error because the update call requires the file to be checked out.

Andrew's code also didn't take into account workflows that would need to be canceled as a result of approving an item. And finally his code didn't allow all the scoping options that I needed (Farm, Web Application, Site Collection, Web, or List). The large bulk of the code is just a series of methods with different loops in them to handle the various scoping capabilities (similar to what I did with gl-replacefieldvalues).

The core code itself is considering all the cases in which an item may need to be either checked in, published, and/or approved. I've also added the ability to dump all changes to a log file as well as to run the command in a "test" mode where it will show you what it would publish but not actually make any changes - I'd strongly recommend you use this first to verify all the changes that will be made. The core code is shown below:

   1: /// <summary>
   2: /// Publishes the list item.
   3: /// </summary>
   4: /// <param name="item">The item.</param>
   5: /// <param name="list">The list.</param>
   6: /// <param name="settings">The settings.</param>
   7: /// <param name="source">The source.</param>
   8: internal static void PublishListItem(SPListItem item, SPList list, Settings settings, string source)
   9: {
  10:  try
  11:  {
  12:   if (item.File != null)
  13:   {
  14:    // We first need to handle the case in which we have a file which means that
  15:    // we have to deal with the possibility that the file may be checked out.
  16:    if (item.Level == SPFileLevel.Checkout)
  17:    {
  18:     // The file is checked out so we now need to check it in - we'll do a major
  19:     // checkin which will result in it being published.
  20:     if (!settings.Test)
  21:     {
  22:      item.File.CheckIn("Checked in by " + source, SPCheckinType.MajorCheckIn);
  23:      // We need to get the File's version of the SPListItem so that we get the changes.
  24:      // Calling item.Update() will fail because the file is no longer checked out.
  25:      item = item.File.Item; // If workflow is supported this should now be in a pending state.
  26:     }
  27:     TaskCounts.Checkin++;
  28:     TaskCounts.Publish++; // The major checkin causes it to be published so we'll track that as well.
  29:     Log(settings, string.Format("Checked in item: {0} ({1})", item.Title, item.Url));
  30:    }
  31:    else if (item.Level == SPFileLevel.Draft && item.ModerationInformation == null)
  32:    {
  33:     // The file isn't checked out but it is in a draft state so we need to publish it.
  34:     if (!settings.Test)
  35:     {
  36:      item.File.Publish("Published by " + source);
  37:      // We need to get the File's version of the SPListItem so that we get the changes.
  38:      // Calling item.Update() will fail because the file is no longer checked out.
  39:      item = item.File.Item; // If workflow is supported this should now be in a pending state.
  40:     }
  41:     TaskCounts.Publish++;
  42:     Log(settings, string.Format("Published item: {0} ({1})", item.Title, item.Url));
  43:    }
  44:   }
  45:  }
  46:  catch (Exception ex)
  47:  {
  48:   TaskCounts.Errors++;
  49:   Log(settings, string.Format("An error occured checking in an item:\r\n{0}", ex.Message));
  50:  }
  51:  
  52:  if (item.ModerationInformation != null)
  53:  {
  54:   // If ModerationInformation is not null then the item supports content approval.
  55:   if (item.File == null &&
  56:    (item.ModerationInformation.Status == SPModerationStatusType.Draft ||
  57:    item.ModerationInformation.Status == SPModerationStatusType.Pending))
  58:   {
  59:    // If content approval is supported but no file is associated with the item then we have
  60:    // to treat it differently.  We simply set the status information directly.
  61:    try
  62:    {
  63:     if (!settings.Test)
  64:     {
  65:      // Because the SPListItem object has no direct approval method we have to 
  66:      // set the information directly (there's no SPFile object to use).
  67:      CancelWorkflows(settings, list, item);
  68:      item.ModerationInformation.Status = SPModerationStatusType.Approved;
  69:      item.ModerationInformation.Comment = "Approved by " + source;
  70:      item.Update(); // Because there's no SPFile object we don't have to worry about the item being checkedout for this to succeed as you can't check it out.
  71:     }
  72:     TaskCounts.Approve++;
  73:     Log(settings, string.Format("Approved item: {0} ({1})", item.Title, item.Url));
  74:    }
  75:    catch (Exception ex)
  76:    {
  77:     TaskCounts.Errors++;
  78:     Log(settings, string.Format("An error occured approving an item:\r\n{0}", ex.Message));
  79:    }
  80:   }
  81:   else
  82:   {
  83:    // The item supports content approval and we have an SPFile object to work with.
  84:    try
  85:    {
  86:     if (item.ModerationInformation.Status == SPModerationStatusType.Pending)
  87:     {
  88:      // The item is pending so it's already been published - we just need to approve.
  89:      if (!settings.Test)
  90:      {
  91:       // Cancel any workflows.
  92:       CancelWorkflows(settings, list, item);
  93:       item.File.Approve("Approved by " + source);
  94:       // We don't need to re-retrieve the item as we're now done with it.
  95:      }
  96:      TaskCounts.Approve++;
  97:      Log(settings, string.Format("Approved item: {0} ({1})", item.Title, item.Url));
  98:     }
  99:    }
 100:    catch (Exception ex)
 101:    {
 102:     TaskCounts.Errors++;
 103:     Log(settings, string.Format("An error occured approving an item:\r\n{0}", ex.Message));
 104:    }
 105:  
 106:    try
 107:    {
 108:     if (item.ModerationInformation.Status == SPModerationStatusType.Draft)
 109:     {
 110:      // The item is in a draft state so we have to first publish it and then approve it.
 111:      if (!settings.Test)
 112:      {
 113:       item.File.Publish("Published by " + source);
 114:       // Cancel any workflows.
 115:       CancelWorkflows(settings, list, item);
 116:       item.File.Approve("Approved by " + source);
 117:       // We don't need to re-retrieve the item as we're now done with it.
 118:      }
 119:      TaskCounts.Publish++;
 120:      TaskCounts.Approve++;
 121:      Log(settings, string.Format("Published item: {0} ({1})", item.Title, item.Url));
 122:     }
 123:    }
 124:    catch (Exception ex)
 125:    {
 126:     TaskCounts.Errors++;
 127:     Log(settings, string.Format("An error occured approving an item:\r\n{0}", ex.Message));
 128:    }
 129:   }
 130:  }
 131: }
 132:  
 133: /// <summary>
 134: /// Cancels the workflows.  This code is a re-engineering of the code that Microsoft uses
 135: /// when approving an item via the browser.  That code is in Microsoft.SharePoint.ApplicationPages.ApprovePage.
 136: /// </summary>
 137: /// <param name="settings">The settings.</param>
 138: /// <param name="list">The list.</param>
 139: /// <param name="item">The item.</param>
 140: private static void CancelWorkflows(Settings settings, SPList list, SPListItem item)
 141: {
 142:  if (list.DefaultContentApprovalWorkflowId != Guid.Empty &&
 143:   item.DoesUserHavePermissions((SPBasePermissions.ApproveItems |
 144:            SPBasePermissions.EditListItems)))
 145:  {
 146:   // If the user has rights to do so then we need to cancel any workflows that
 147:   // are associated with the item.  This is based on how the 
 148:   SPSecurity.RunWithElevatedPrivileges(
 149:    delegate
 150:     {
 151:      foreach (SPWorkflow workflow in item.Workflows)
 152:      {
 153:       if (workflow.ParentAssociation.Id !=
 154:        list.DefaultContentApprovalWorkflowId)
 155:       {
 156:        continue;
 157:       }
 158:       SPWorkflowManager.CancelWorkflow(workflow);
 159:       Log(settings,
 160:        string.Format("Cancelling workflow {0} for item: {1} ({2})",
 161:             workflow.WebId, item.Title, item.Url));
 162:      }
 163:     });
 164:  }
 165: }
The syntax of the command I created can be seen below:

C:\>stsadm -help gl-publishitems

stsadm -o gl-publishitems

Publishes all items at a given scope.  Use -test to verify what will be published before executing.

Parameters:
        [-url <url to publish>]
        -scope <Farm | WebApplication | Site | Web | List>
        [-quiet]
        [-test]
        [-logfile <log file>]

Here's an example of how to publish all items in a given site collection:

stsadm -o gl-publishitems -url "http://intranet/hr" -scope site -logfile "c:\publish.log"

2Oct/077

Enumerate Page Web Parts

As part of the upgrade I needed to be able to fix some web parts that did not migrate correctly (either during the upgrade itself or as a result of moving a web). Before I started messing around with the web parts though I wanted to be able to see what I was dealing with. So I decided to create this simple command called gl-enumpagewebparts that would enable me to list out in XML all the web parts that are on a given page (open or closed).

One thing that I found that was very interesting was that the web part manager export method treats V2 and V3 web parts very differently. But perhaps the biggest annoyance I found was that I couldn't get the web part zone from the web part instance itself - I had to use the web part manager (SPLimitedWebPartManager) to get it (took me longer than I'd like to admit to figure that out). This command is really quite simple - it takes in an url to a web part page, loads up an SPLimitedWebPartManager (for both the shared and personal views) and then loops through the WebParts collection outputting the results as XML.

I created three separate methods to get the XML details - one is verbose and essentially just uses the built in Export() method to get the XML (you can get these results via the -verbose parameter), another is a bit simpler and is constructed by hand (this is the default) and a third is actually for use by another command that I created which I'll be documenting soon. The core code is shown below:

   1: /// <summary>
   2: /// Runs the specified command.
   3: /// </summary>
   4: /// <param name="command">The command.</param>
   5: /// <param name="keyValues">The key values.</param>
   6: /// <param name="output">The output.</param>
   7: /// <returns></returns>
   8: public override int Run(string command, StringDictionary keyValues, out string output)
   9: {
  10:  output = string.Empty;
  11:  
  12:  InitParameters(keyValues);
  13:  
  14:  string url = Params["url"].Value;
  15:  
  16:  XmlDocument xmlDoc = new XmlDocument();
  17:  xmlDoc.AppendChild(xmlDoc.CreateElement("WebParts"));
  18:  xmlDoc.DocumentElement.SetAttribute("page", url);
  19:  
  20:  using (SPSite site = new SPSite(url))
  21:  using (SPWeb web = site.OpenWeb()) // The url contains a filename so AllWebs[] will not work unless we want to try and parse which we don't
  22:  {
  23:   XmlElement shared = xmlDoc.CreateElement("Shared");
  24:   xmlDoc.DocumentElement.AppendChild(shared);
  25:  
  26:   SPLimitedWebPartManager webPartMngr = web.GetLimitedWebPartManager(url, PersonalizationScope.Shared);
  27:   
  28:   string tempXml = string.Empty;
  29:   foreach (WebPart wp in webPartMngr.WebParts)
  30:   {
  31:    if (Params["verbose"].UserTypedIn)
  32:     tempXml += GetWebPartDetails(wp, webPartMngr);
  33:    else
  34:     tempXml += GetWebPartDetailsSimple(wp, webPartMngr);
  35:   }
  36:   shared.InnerXml = tempXml;
  37:  
  38:   XmlElement user = xmlDoc.CreateElement("User");
  39:   xmlDoc.DocumentElement.AppendChild(user);
  40:   
  41:   webPartMngr = web.GetLimitedWebPartManager(url, PersonalizationScope.User);
  42:   tempXml = string.Empty;
  43:   foreach (WebPart wp in webPartMngr.WebParts)
  44:   {
  45:    if (Params["verbose"].UserTypedIn)
  46:     tempXml += GetWebPartDetails(wp, webPartMngr);
  47:    else
  48:     tempXml += GetWebPartDetailsSimple(wp, webPartMngr);
  49:   }
  50:   user.InnerXml = tempXml;
  51:  
  52:  }
  53:  
  54:  output += Utilities.GetFormattedXml(xmlDoc);
  55:  
  56:  return 1;
  57: }
  58:  
  59: #endregion
  60:  
  61: /// <summary>
  62: /// Gets the web part details.
  63: /// </summary>
  64: /// <param name="wp">The web part.</param>
  65: /// <param name="manager">The web part manager.</param>
  66: /// <returns></returns>
  67: internal static string GetWebPartDetails(WebPart wp, SPLimitedWebPartManager manager)
  68: {
  69:  StringBuilder sb = new StringBuilder();
  70:  
  71:  XmlTextWriter xmlWriter = new XmlTextWriter(new StringWriter(sb));
  72:  xmlWriter.Formatting = Formatting.Indented;
  73:  manager.ExportWebPart(wp, xmlWriter);
  74:  xmlWriter.Flush();
  75:  
  76:  XmlDocument xmlDoc = new XmlDocument();
  77:  xmlDoc.LoadXml(sb.ToString());
  78:  
  79:  XmlElement elem = xmlDoc.DocumentElement;
  80:  if (xmlDoc.DocumentElement.Name == "webParts")
  81:  {
  82:   elem = (XmlElement)xmlDoc.DocumentElement.ChildNodes[0];
  83:   
  84:   // We've found a v3 web part but the export method does not export what the zone ID is so we
  85:   // have to manually add that in.  Unfortunately the Zone property is always null because we are
  86:   // using a SPLimitedWebPartManager so we have to use the helper method GetZoneID to set the zone ID.
  87:   XmlElement property = xmlDoc.CreateElement("property");
  88:   property.SetAttribute("name", "ZoneID");
  89:   property.SetAttribute("type", "string");
  90:   property.InnerText = manager.GetZoneID(wp);
  91:   elem.ChildNodes[1].ChildNodes[0].AppendChild(property);
  92:  }
  93:  
  94:  return elem.OuterXml.Replace(" xmlns=\"\"", ""); // Just some minor cleanup to deal with erroneous namespace tags added due to the zoneID being added manually.
  95: }

The syntax of the command I created can be seen below.

C:\>stsadm -help gl-enumpagewebparts

stsadm -o gl-enumpagewebparts

Lists all the web parts that have been added to the specified page.

Parameters:
        -url <web part page URL>
        [-verbose]

Here’s an example of how to list all the web parts on a given page and dump to a text file:

stsadm -o gl-enumpagewebparts -url "http://intranet/hr/pages/default.aspx" -verbose > webparts.xml

13Sep/0730

Re-Ghosting Pages

After running a test upgrade I discovered that one page particular was showing up as un-ghosted (or customized) despite my setting the option to reset all pages to the site definition when I ran the upgrade. I attempted to use the browser to reset the page (in this case http://intranet/sitedirectory/lists/sites/summary.aspx) but that had no affect.

I decided that I needed to get more information about how the un-ghosting process works. The first thing I needed to do was see if there were any other pages with the same problem. To do this I created a command called gl-enumunghostedfiles. I know there are versions of the same command out there already but I found I needed something a bit more capable so that I could search an entire site collection and not just a single web.

After creating this command (detailed below) I was surprised to see that this summary.aspx page was not showing up as un-ghosted at all. When looking for an un-ghosted file you typically just check the CustomizedPageStatus property of an SPFile object. If this returns back as Customized (so much better than saying "un-ghosted" - not sure why this term came up and why I'm proliferating it :) ) then the page is un-ghosted. If you look internally at how this property is evaluated you'll see that the code is checking for the presense of a property in the Properties collection called "vti_setuppath" - if this property is not null or empty then it checks for another property called "vti_hasdefaultcontent" and if it either doesn't find this or it's set to false then it returns back a value of Customized. You can see this in the code below:

   1: public SPCustomizedPageStatus CustomizedPageStatus
   2: {
   3:     get
   4:     {
   5:         if (!string.IsNullOrEmpty(this.SetupPath))
   6:         {
   7:             bool flag = false;
   8:             try
   9:             {
  10:                 object obj2 = this.Properties["vti_hasdefaultcontent"];
  11:                 if (obj2 != null)
  12:                 {
  13:                     flag = bool.Parse((string) obj2);
  14:                 }
  15:             }
  16:             catch (FormatException)
  17:             {
  18:             }
  19:             if (flag)
  20:             {
  21:                 return SPCustomizedPageStatus.Uncustomized;
  22:             }
  23:             return SPCustomizedPageStatus.Customized;
  24:         }
  25:         return SPCustomizedPageStatus.None;
  26:     }
  27: }

What I found when I looked closer at this page that I knew (based on the shear appearance of the page) was un-ghosted was that the "vti_hasdefaultcontent" property was set to "true" and the "vti_setuppath" property was set to the old 2003 template path. I spent many hours trying to figure out how to re-ghost this page and ended up ultimately unsuccessful. If anyone has any thoughts on this I'd love to hear them (I've tried updating the SetupPath and SetupPathVerion fields in the AllDocs table to point the file to the new template but that just resulted in an unknown error when loading the page - changing the "vti_hasdefaultcontent" property manually also didn't work as the value would not persist and I couldn't find where it's stored in the DB and changing this value in memory would allow the SPRequest.RevertContentStreams() method to be called but that would just throw a file not found exception as it is unable to locate the template file despite copying the file to various suspect locations).

As a result of my efforts though I do have a fairly robust command to re-ghost pages which does seem to work with another issue I encountered. I found that when I imported a list from another site the pages (views) for the list were showing up as un-ghosted but when I tried to use the RevertContentStream method of the SPFile object it had no affect.

After messing around with it for a while I discovered that if I called the internal SPRequest.RevertContentStreams() method directly and did not follow that call up with the call to SPRequest.UpdateFileOrFolderProperties() as the SPFile.RevertContentStream() method does then I can get the file to successfully be re-ghosted. I've got an email out to Microsoft to see if they can explain why this is so but in the mean-time it seems to work. The two commands that I created are detailed further below.

1. gl-enumunghostedfiles

The code for this command is pretty simple - I've basically just got two recursive methods, one for the web sites and another for folders. I allowed a parameter to be passed in to determine whether the code should recurse sub webs or not. The code is shown below:

   1: public class EnumUnGhostedFiles : SPOperation
   2: {
   3:     /// <summary>
   4:     /// Initializes a new instance of the <see cref="EnumUnGhostedFiles"/> class.
   5:     /// </summary>
   6:     public EnumUnGhostedFiles()
   7:     {
   8:         SPParamCollection parameters = new SPParamCollection();
   9:         parameters.Add(new SPParam("url", "url", true, null, new SPUrlValidator(), "Please specify the site url."));
  10:         parameters.Add(new SPParam("recursesubwebs", "recurse", false, null, null));
  11:         Init(parameters, "\r\n\r\nReturns a list of all unghosted (customized) files for a web.\r\n\r\nParameters:\r\n\t-url <web site url>\r\n\t[-recursesubwebs]");
  12:     }
  13:  
  14:     #region ISPStsadmCommand Members
  15:  
  16:     /// <summary>
  17:     /// Gets the help message.
  18:     /// </summary>
  19:     /// <param name="command">The command.</param>
  20:     /// <returns></returns>
  21:     public override string GetHelpMessage(string command)
  22:     {
  23:         return HelpMessage;
  24:     }
  25:  
  26:     /// <summary>
  27:     /// Runs the specified command.
  28:     /// </summary>
  29:     /// <param name="command">The command.</param>
  30:     /// <param name="keyValues">The key values.</param>
  31:     /// <param name="output">The output.</param>
  32:     /// <returns></returns>
  33:     public override int Run(string command, System.Collections.Specialized.StringDictionary keyValues, out string output)
  34:     {
  35:         output = string.Empty;
  36:  
  37:         InitParameters(keyValues);
  38:  
  39:         string url = Params["url"].Value;
  40:         bool recurse = Params["recursesubwebs"].UserTypedIn;
  41:         List<string> unghostedFiles = new List<string>();
  42:  
  43:         using (SPSite site = new SPSite(url))
  44:         {
  45:             using (SPWeb web = site.OpenWeb())
  46:             {
  47:                 if (recurse)
  48:                 {
  49:                     RecurseSubWebs(web, ref unghostedFiles);
  50:                 }
  51:                 else 
  52:                     CheckFoldersForUnghostedFiles(web.RootFolder, ref unghostedFiles);
  53:             }
  54:         }
  55:  
  56:         if (unghostedFiles.Count == 0)
  57:         {
  58:             output += "There are no unghosted (customized) files on the current web.\r\n";
  59:         }
  60:         else
  61:         {
  62:             output += "The following files are unghosted:";
  63:  
  64:             foreach (string fileName in unghostedFiles)
  65:             {
  66:                 output += "\r\n\t" + fileName;
  67:             }
  68:         }
  69:  
  70:         return 1;
  71:     }
  72:  
  73:     #endregion
  74:  
  75:     /// <summary>
  76:     /// Recurses the sub webs.
  77:     /// </summary>
  78:     /// <param name="web">The web.</param>
  79:     /// <param name="unghostedFiles">The unghosted files.</param>
  80:     private static void RecurseSubWebs(SPWeb web, ref List<string>unghostedFiles)
  81:     {
  82:         foreach (SPWeb subweb in web.Webs)
  83:         {
  84:             try
  85:             {
  86:                 RecurseSubWebs(subweb, ref unghostedFiles);
  87:             }
  88:             finally
  89:             {
  90:                 subweb.Dispose();
  91:             }
  92:         }
  93:         CheckFoldersForUnghostedFiles(web.RootFolder, ref unghostedFiles);
  94:     }
  95:  
  96:     /// <summary>
  97:     /// Checks the folders for unghosted files.
  98:     /// </summary>
  99:     /// <param name="folder">The folder.</param>
 100:     /// <param name="unghostedFiles">The unghosted files.</param>
 101:     private static void CheckFoldersForUnghostedFiles(SPFolder folder, ref List<string> unghostedFiles)
 102:     {
 103:         foreach (SPFolder sub in folder.SubFolders)
 104:         {
 105:             CheckFoldersForUnghostedFiles(sub, ref unghostedFiles);
 106:         }
 107:  
 108:         foreach (SPFile file in folder.Files)
 109:         {
 110:             if (file.CustomizedPageStatus == SPCustomizedPageStatus.Customized)
 111:             {
 112:                 if (!unghostedFiles.Contains(file.ServerRelativeUrl))
 113:                     unghostedFiles.Add(file.ServerRelativeUrl);
 114:             }
 115:         }
 116:     }
 117: }

The syntax of the command can be seen below:

C:\>stsadm -help gl-enumunghostedfiles

stsadm -o gl-enumunghostedfiles

Returns a list of all unghosted (customized) files for a web.

Parameters:
        -url <web site url>
        [-recursesubwebs]

The following table summarizes the command and its various parameters:

Command Name Availability Build Date
gl-enumunghostedfiles WSS v3, MOSS 2007 Released: 9/13/2007

Parameter Name Short Form Required Description Example Usage
url   Yes URL to analyze. -url http://intranet/
recursesubwebs recurse No If not specified then only the single web will be considered.  To recurse the web and all it’s sub-webs pass in this parameter. -recursesubwebs

-recurse

Here’s an example of how to return the un-ghosted files for a root site collection and all sub-webs:

stsadm –o gl-enumunghostedfiles -url "http://intranet/" -recursesubwebs

You can see a sample of what running the above command will produce - your results will most likely be very different:

C:\>stsadm –o gl-enumunghostedfiles -url "http://intranet/" -recursesubwebs
The following files are unghosted:
        /News/Pages/Default.aspx
        /Reports/Pages/default.aspx
        /SearchCenter/Pages/people.aspx
        /SearchCenter/Pages/default.aspx
        /SearchCenter/Pages/peopleresults.aspx
        /SearchCenter/Pages/results.aspx
        /SearchCenter/Pages/advanced.aspx
        /SiteDirectory/Pages/category.aspx
        /SiteDirectory/Pages/sitemap.aspx
        /Pages/Default.aspx
        /FormServerTemplates/Forms/InfoPath Form Template/template.doc
        /Variation Labels/NewForm.aspx
        /Variation Labels/EditForm.aspx
        /Variation Labels/AllItems.aspx
        /Variation Labels/DispForm.aspx
        /_catalogs/masterpage/VariationRootPageLayout.aspx
        /_catalogs/wp/siteFramer.dwp
        /_catalogs/wp/IViewWebPart.dwp
        /_catalogs/wp/IndicatorWebPart.dwp
        /_catalogs/wp/BusinessDataFilter.dwp
        /_catalogs/wp/KpiListWebPart.dwp
        /_catalogs/wp/SummaryLink.webpart
        /_catalogs/wp/ContentQuery.webpart
        /_catalogs/wp/ThisWeekInPictures.DWP
        /_catalogs/wp/SearchBestBets.webpart
        /_catalogs/wp/CategoryWebPart.webpart
        /_catalogs/wp/QueryStringFilter.webpart
        /_catalogs/wp/SpListFilter.dwp
        /_catalogs/wp/WSRPConsumerWebPart.dwp
        /_catalogs/wp/searchpaging.dwp
        /_catalogs/wp/contactwp.dwp
        /_catalogs/wp/searchstats.dwp
        /_catalogs/wp/UserContextFilter.webpart
        /_catalogs/wp/owacontacts.dwp
        /_catalogs/wp/SearchCoreResults.webpart
        /_catalogs/wp/BusinessDataActionsWebPart.dwp
        /_catalogs/wp/owainbox.dwp
        /_catalogs/wp/FilterActions.dwp
        /_catalogs/wp/AdvancedSearchBox.dwp
        /_catalogs/wp/BusinessDataAssociationWebPart.webpart
        /_catalogs/wp/RssViewer.webpart
        /_catalogs/wp/owatasks.dwp
        /_catalogs/wp/SearchHighConfidence.webpart
        /_catalogs/wp/PeopleSearchBox.dwp
        /_catalogs/wp/SearchActionLinks.webpart
        /_catalogs/wp/PageContextFilter.webpart
        /_catalogs/wp/owacalendar.dwp
        /_catalogs/wp/AuthoredListFilter.webpart
        /_catalogs/wp/searchsummary.dwp
        /_catalogs/wp/CategoryResultsWebPart.webpart
        /_catalogs/wp/owa.dwp
        /_catalogs/wp/OlapFilter.dwp
        /_catalogs/wp/BusinessDataDetailsWebPart.webpart
        /_catalogs/wp/TableOfContents.webpart
        /_catalogs/wp/BusinessDataListWebPart.webpart
        /_catalogs/wp/TasksAndTools.webpart
        /_catalogs/wp/Microsoft.Office.Excel.WebUI.dwp
        /_catalogs/wp/DateFilter.dwp
        /_catalogs/wp/TextFilter.dwp
        /_catalogs/wp/SearchBox.dwp
        /_catalogs/wp/BusinessDataItemBuilder.dwp
        /_catalogs/wp/TopSitesWebPart.webpart
        /_catalogs/wp/PeopleSearchCoreResults.webpart

2. gl-reghostfile

This command takes what should be a very simple call to SPFile.RevertContentStream() and attempts to handle those odd cases that I outlined above. Therefore the code is a bit of a mess and frankly nothing I'm proud of (mainly because I'm pissed I wasn't able to solve the problem). The code, which uses some reflection in order to utilize some internal objects, is shown below (sorry about the poor formatting - this blog template is less than ideal for code samples):

   1: /// <summary>
   2: /// Runs the specified command.
   3: /// </summary>
   4: /// <param name="command">The command.</param>
   5: /// <param name="keyValues">The key values.</param>
   6: /// <param name="output">The output.</param>
   7: /// <returns></returns>
   8: public override int Execute(string command, System.Collections.Specialized.StringDictionary keyValues, out string output)
   9: {
  10:     output = string.Empty;
  11:     Verbose = true;
  12:     
  13:  
  14:     string url = Params["url"].Value;
  15:     bool force = Params["force"].UserTypedIn;
  16:     string scope = Params["scope"].Value.ToLowerInvariant();
  17:     bool haltOnError = Params["haltonerror"].UserTypedIn;
  18:  
  19:     switch (scope)
  20:     {
  21:         case "file":
  22:             using (SPSite site = new SPSite(url))
  23:             using (SPWeb web = site.OpenWeb())
  24:             {
  25:                 SPFile file = web.GetFile(url);
  26:                 if (file == null)
  27:                 {
  28:                     throw new FileNotFoundException(string.Format("File '{0}' not found.", url), url);
  29:                 }
  30:  
  31:                 Reghost(site, web, file, force, haltOnError);
  32:             }
  33:             break;
  34:         case "list":
  35:             using (SPSite site = new SPSite(url))
  36:             using (SPWeb web = site.OpenWeb())
  37:             {
  38:                 SPList list = Utilities.GetListFromViewUrl(web, url);
  39:                 ReghostFilesInList(site, web, list, force, haltOnError);
  40:             }
  41:             break;
  42:         case "web":
  43:             bool recurseWebs = Params["recursewebs"].UserTypedIn;
  44:             using (SPSite site = new SPSite(url))
  45:             using (SPWeb web = site.AllWebs[Utilities.GetServerRelUrlFromFullUrl(url)])
  46:             {
  47:                 ReghostFilesInWeb(site, web, recurseWebs, force, haltOnError);
  48:             }
  49:             break;
  50:         case "site":
  51:             using (SPSite site = new SPSite(url))
  52:             {
  53:                 ReghostFilesInSite(site, force, haltOnError);
  54:             }
  55:             break;
  56:         case "webapplication":
  57:             SPWebApplication webApp = SPWebApplication.Lookup(new Uri(url));
  58:             Log("Progress: Analyzing files in web application '{0}'.", url);
  59:  
  60:             foreach (SPSite site in webApp.Sites)
  61:             {
  62:                 try
  63:                 {
  64:                     ReghostFilesInSite(site, force, haltOnError);
  65:                 }
  66:                 finally
  67:                 {
  68:                     site.Dispose();
  69:                 }
  70:             }
  71:             break;
  72:             
  73:     }
  74:     return OUTPUT_SUCCESS;
  75: }
  76:  
  77: #endregion
  78:  
  79: /// <summary>
  80: /// Reghosts the files in site.
  81: /// </summary>
  82: /// <param name="site">The site.</param>
  83: /// <param name="force">if set to <c>true</c> [force].</param>
  84: /// <param name="throwOnError">if set to <c>true</c> [throw on error].</param>
  85: public static void ReghostFilesInSite(SPSite site, bool force, bool throwOnError)
  86: {
  87:     Log("Progress: Analyzing files in site collection '{0}'.", site.Url);
  88:     foreach (SPWeb web in site.AllWebs)
  89:     {
  90:         try
  91:         {
  92:             ReghostFilesInWeb(site, web, false, force, throwOnError);
  93:         }
  94:         finally
  95:         {
  96:             web.Dispose();
  97:         }
  98:     }
  99: }
 100:  
 101: /// <summary>
 102: /// Reghosts the files in web.
 103: /// </summary>
 104: /// <param name="site">The site.</param>
 105: /// <param name="web">The web.</param>
 106: /// <param name="recurseWebs">if set to <c>true</c> [recurse webs].</param>
 107: /// <param name="force">if set to <c>true</c> [force].</param>
 108: /// <param name="throwOnError">if set to <c>true</c> [throw on error].</param>
 109: public static void ReghostFilesInWeb(SPSite site, SPWeb web, bool recurseWebs, bool force, bool throwOnError)
 110: {
 111:     Log("Progress: Analyzing files in web '{0}'.", web.Url);
 112:     foreach (SPFile file in web.Files)
 113:     {
 114:         if (file.CustomizedPageStatus != SPCustomizedPageStatus.Customized && !force)
 115:             continue;
 116:  
 117:         Reghost(site, web, file, force, throwOnError);    
 118:     }
 119:     foreach (SPList list in web.Lists)
 120:     {
 121:         ReghostFilesInList(site, web, list, force, throwOnError);
 122:     }
 123:     
 124:     if (recurseWebs)
 125:     {
 126:         foreach (SPWeb childWeb in web.Webs)
 127:         {
 128:             try
 129:             {
 130:                 ReghostFilesInWeb(site, childWeb, true, force, throwOnError);
 131:             }
 132:             finally
 133:             {
 134:                 childWeb.Dispose();
 135:             }
 136:         }
 137:     }
 138: }
 139:  
 140: /// <summary>
 141: /// Reghosts the files in list.
 142: /// </summary>
 143: /// <param name="site">The site.</param>
 144: /// <param name="web">The web.</param>
 145: /// <param name="list">The list.</param>
 146: /// <param name="force">if set to <c>true</c> [force].</param>
 147: /// <param name="throwOnError">if set to <c>true</c> [throw on error].</param>
 148: public static void ReghostFilesInList(SPSite site, SPWeb web, SPList list, bool force, bool throwOnError)
 149: {
 150:     if (list.BaseType != SPBaseType.DocumentLibrary)
 151:         return;
 152:  
 153:     Log("Progress: Analyzing files in list '{0}'.", list.RootFolder.ServerRelativeUrl);
 154:  
 155:     foreach (SPListItem item in list.Items)
 156:     {
 157:         if (item.File == null)
 158:             continue;
 159:  
 160:         Reghost(site, web, item.File, force, throwOnError);
 161:     }
 162: }
 163:  
 164: /// <summary>
 165: /// Reghosts the specified file.
 166: /// </summary>
 167: /// <param name="site">The site.</param>
 168: /// <param name="web">The web.</param>
 169: /// <param name="file">The file.</param>
 170: /// <param name="force">if set to <c>true</c> [force].</param>
 171: /// <param name="throwOnError">if set to <c>true</c> [throw on error].</param>
 172: public static void Reghost(SPSite site, SPWeb web, SPFile file, bool force, bool throwOnError)
 173: {
 174:     try
 175:     {
 176:         string fileUrl = site.MakeFullUrl(file.ServerRelativeUrl);
 177:         if (file.CustomizedPageStatus != SPCustomizedPageStatus.Customized && !force)
 178:         {
 179:             Log("Progress: " + file.ServerRelativeUrl + " was not unghosted (customized).");
 180:             return;
 181:         }
 182:         if (file.CustomizedPageStatus != SPCustomizedPageStatus.Customized && force)
 183:         {
 184:             if (!string.IsNullOrEmpty((string)file.Properties["vti_setuppath"]))
 185:             {
 186:                 file.Properties["vti_hasdefaultcontent"] = "false";
 187:  
 188:                 string setupPath = (string)file.Properties["vti_setuppath"];
 189:                 string rootPath = SPUtility.GetGenericSetupPath("Template");
 190:  
 191:                 if (!File.Exists(Path.Combine(rootPath, setupPath)))
 192:                 {
 193:                     string message = "The template file (" + Path.Combine(rootPath, setupPath) +
 194:                                      ") does not exist so re-ghosting (uncustomizing) will not be possible.";
 195:  
 196:                     // something's wrong with the setup path - lets see if we can fix it
 197:                     // Try and remove a leading locale if present
 198:                     setupPath = "SiteTemplates\\" + setupPath.Substring(5);
 199:                     if (File.Exists(Path.Combine(rootPath, setupPath)))
 200:                     {
 201:                         message += "  It appears that a possible template match does exist at \"" +
 202:                                    Path.Combine(rootPath, setupPath) +
 203:                                    "\" however this tool currently is not able to handle pointing the file to the correct template path.  This scenario is most likely due to an upgrade from SPS 2003.";
 204:  
 205:                         // We found a matching file so reset the property and update the file.
 206:                         // ---  I wish this would work but it simply doesn't - something is preventing the
 207:                         //      update from occuring.  Manipulating the database directly results in a 404
 208:                         //      when attempting to load the "fixed" page so there's gotta be something beyond
 209:                         //      just updating the setuppath property.
 210:                         //file.Properties["vti_setuppath"] = setupPath;
 211:                         //file.Update();
 212:                     }
 213:                     throw new FileNotFoundException(message, setupPath);
 214:                 }
 215:             }
 216:         }
 217:         Log("Progress: Re-ghosting (uncustomizing) '{0}'", fileUrl);
 218:         file.RevertContentStream();
 219:  
 220:         file = web.GetFile(fileUrl);
 221:         if (file.CustomizedPageStatus == SPCustomizedPageStatus.Customized)
 222:         {
 223:             // Still unsuccessful so take measures further
 224:             if (force)
 225:             {
 226:                 object request = Utilities.GetSPRequestObject(web);
 227:  
 228:                 // I found some cases where calling this directly was the only way to force the re-ghosting of the file.
 229:                 // I think the trick is that it's not updating the file properties after doing the revert (the
 230:                 // RevertContentStream method will call SPRequest.UpdateFileOrFolderProperties() immediately after the 
 231:                 // RevertContentStreams call but ommitting the update call seems to make a difference.
 232:                 Utilities.ExecuteMethod(request, "RevertContentStreams",
 233:                                         new[] { typeof(string), typeof(string), typeof(bool) },
 234:                                         new object[] { web.Url, file.Url, file.CheckOutStatus != SPFile.SPCheckOutStatus.None });
 235:  
 236:  
 237:                 Utilities.ExecuteMethod(file, "DirtyThisFileObject", new Type[] { }, new object[] { });
 238:  
 239:                 file = web.GetFile(fileUrl);
 240:  
 241:                 if (file.CustomizedPageStatus == SPCustomizedPageStatus.Customized)
 242:                 {
 243:                     throw new SPException("Unable to re-ghost (uncustomize) file " + file.ServerRelativeUrl);
 244:                 }
 245:                 Log("Progress: " + file.ServerRelativeUrl + " was re-ghosted (uncustomized)!");
 246:                 return;
 247:             }
 248:             throw new SPException("Unable to re-ghost (uncustomize) file " + file.ServerRelativeUrl);
 249:         }
 250:         Log("Progress: " + file.ServerRelativeUrl + " was re-ghosted (uncustomized)!");
 251:     }
 252:     catch (Exception ex)
 253:     {
 254:         if (throwOnError)
 255:         {
 256:             Log("ERROR:");
 257:             throw;
 258:         }
 259:         Log("ERROR: {0}", ex.Message);
 260:     }
 261:  
 262: }

The syntax of the command can be seen below:

C:\>stsadm -help gl-reghostfile

stsadm -o gl-reghostfile


Reghosts a file (use force to override CustomizedPageStatus check).

Parameters:
        -url <url to analyze>
        [-force]
        [-scope <WebApplication | Site | Web | List | File>]
        [-recursewebs (applies to Web scope only)]
        [-haltonerror]

The following table summarizes the command and its various parameters:

Command Name Availability Build Date
gl-reghostfile WSS v3, MOSS 2007 Released: 9/13/2007
Updated: 12/14/2008 

Parameter Name Short Form Required Description Example Usage
url   Yes URL to analyze.  If scope is “File” then URL must point to a valid file within a Web.  If scope is “List” then URL must point to a valid List within a Web. -url "http://intranet/sitedirectory/lists/sites/summary.aspx"
force   No Attempts to force the reghosting of file(s) using internal API method calls (via reflection). -force
scope   No (defaults to File) The scope to look at when reghosting files.  Valid values are “WebApplication”, “Site”, “Web”, “List”, or “File”. -scope file
recursewebs recurse No Applies to “Web” scope only.  If a scope of “Web” is not specified then only the single web will be considered.  To recurse the web and all it’s sub-webs pass in this parameter. -recursewebs

-recurse
haltonerror halt No If an error occurs then stop processing other files within the specified scope. -haltonerror

-halt

Here’s an example of how to force the reghosting of a file:

stsadm –o gl-reghostfile -url "http://intranet/sitedirectory/lists/sites/summary.aspx" –scope file -force

If I'm able to get any answers to the issues that remain unsolved for me I'll be sure to post them here (especially if I'm able to fix the issues).