I've moved my blog to!. Please update your links. This blog is no longer in use--you can find all posts and comments at my new blog; I will no longer be posting to this site and comments have been disabled.

Wednesday, April 30, 2008

Programmatically Setting Web Part Audience Targeting

I've been doing some work on my gl-exportlistitem2 and gl-addlistitem commands so that I can support the import of web part pages.  I thought I was about done until I discovered that I had an issue with pages and web parts that were using audience targeting.  There was actually two issues - one was that when I imported a page to another farm the GUID used to store the audience didn't match up so it would lose its setting for the page.  The other issue was that some web parts (specifically anything that is not a V2 web part) do not export the AuthorizationFilter property which is where the audience settings are stored.  I find this very odd that they chose to omit this property when exported.  Note that for V2 web parts the audience information is exported because it is stored in the IsIncludedFilter property of the web part which has been marked as obsolete (internally this property just references the AuthorizationFilter property).

When I set out to do this I thought it would be pretty easy - just store the name with the audience ID and store the configuration information in a MetaData element that I have which I could then use to set the property during the import.  The problem that I ran into was the format of the AuthorizationFilter property - it's just not documented anywhere!  If you view the MSDN documentation for the property it says the following:

The Web Part infrastructure does not implement any default behavior for the AuthorizationFilter property. However, the property is provided so that you can assign an arbitrary string value to a custom Web Part. This property can be checked by SPWebPartManager during its AuthorizeWebPart event to determine whether the control can be added to the page.

The thing is - there's nothing arbitrary about this property - it has a very explicit format which if you don't follow will result in the web part failing to process the audience settings - the format of this matches the format of the Audience field of the web part page - if you mess that field up you will get an error when trying to get into the page settings.

So what is the format of the property?  First you need to understand that there are three types of audience information that the property is storing: Global Audiences created via the SSP; Distribution Lists; SharePoint Groups.  Each of these values is stored slightly differently and the order of their appearance is important.  Each item is delimited with two semi-colons: ";;".

The global audience is stored as a GUID and is the only type of item that can be stored without any delimiter information if no other items exist.  Multiple items are separated with a comma.

The distribution list is stored as an LDAP string.  Multiple items are separated with a new line character ("\n").

The SharePoint groups are stored as a named value (i.e., "Members", "Owners", etc.) and multiple items are separated with a comma.

So putting it all together you would construct a string containing one or more elements of each like so:

   1: string[] globalAudienceIDs = new string[] {"e4687e64-c9d8-4860-bbc3-ec036bf9915d"};
   2: string[] dlDistinguishedNames = new string[] {"cn=group1,cn=users,dc=spdev,dc=com", "cn=group2,cn=users,dc=spdev,dc=com"};
   3: string[] sharePointGroupNames = new string[] {"Demo Members", "Demo Owners"};
   5: string result = string.Format("{0};;{1};;{2}",
   6:                               string.Join(",", globalAudienceIDs),
   7:                               string.Join("\n", dlDistinguishedNames),
   8:                               string.Join(",", sharePointGroupNames));

That being said - if you can avoid hard coding this formatting than you should.  So how do you avoid it - you use the GetAudienceIDsAsText method of the AudienceManager class.  To use this method you would do something like the following:

   1: string[] globalAudienceIDs = new string[] {"e4687e64-c9d8-4860-bbc3-ec036bf9915d"};
   2: string[] dlDistinguishedNames = new string[] {"cn=group1,cn=users,dc=spdev,dc=com", "cn=group2,cn=users,dc=spdev,dc=com"};
   3: string[] sharePointGroupNames = new string[] {"Demo Members", "Demo Owners"};
   5: string result = AudienceManager.GetAudienceIDsAsText(globalAudienceIDs, dlDistinguishedNames, sharePointGroupNames);

What's cool is that there is a reciprocal method called GetAudienceIDsFromText which will give you the IDs based on the string.

In my particular case I wasn't able to use these and had to know the format of the string because I needed to supplement the GUIDs with the named value so that I could do an import in a different farm.

Note that this same format is used for the Audience field of a publishing page.

Monday, April 14, 2008

Fix Variation Relationships List

If you've read my recent post, Fun with Variations, then you know that I've been doing a lot of work with variations lately.  One of the things that I had to figure out a solution to was how to prevent the hidden Relationships List from getting out of sync with the pages in our publishing site when we migrated content from our authoring farm to our publishing farm.  For specifics on what the Relationships List is see the aforementioned post which also touches upon some issues with it.

Breaking this list is extremely easy - let's say that you have an authoring farm where your content authors create your initial pages and then you would like to migrate those pages to your public publishing farm using the content deployment API (so you could use my gl-exportlistitem and gl-importlistitem commands which use the API or write your own or use Central Admin's content deployment jobs).  In most cases the page will appear to have migrated just fine but you may notice a couple things that aren't quite right - first, the Variation Label Menu is no longer working - you think to yourself, "well that's just a navigation thing - maybe if I use the editing page toolbar to update the variations then the navigation will 'fix itself'?".  So you go into the toolbar and click "Tools -> Update Variation...".  And suddenly you are presented with this very unhelpful error stating that a page already exists at the target location:


So what do you do now?  Well, you could delete the page in the other variations but then you'll lose all history and any translations that you want to keep so that's not a practical solution.  The other option is to try and understand what the failure really is and then fix that.  In this case it's the linking between the Relationships List, the imported page, and the matching pages throughout all the variations.  Thus comes the creation of my new command called gl-fixvariationrelationships.

What this command does is for each page in the source variations Pages library it loops through all variations and makes sure that the GroupID field matches and then it looks for an entry in the Relationships List matching the URL of the page and if it doesn't find an entry then it creates one and if it does find one then it makes sure that the values are correct.  It's important to note that if your setup has page variations that are named differently from one variation to another then you'll have to fix the issues manually as there's no way for me to handle this scenario (I'd recommend keeping page names consistent regardless of this issue though - it's just easier to follow what page goes with what).

I probably should have done a better job abstracting this code into separate method calls to make it easier to read but time has been kind of constrained lately and I just needed to get it done.  Anyway, here's the code:

   2: /// <summary>
   3: /// Processes the specified site.
   4: /// </summary>
   5: /// <param name="site">The site.</param>
   6: /// <param name="verbose">if set to <c>true</c> [verbose].</param>
   7: /// <param name="pageName">Name of the page.</param>
   8: public static void Process(SPSite site, bool verbose, string pageName)
   9: {
  10:     m_verbose = verbose;
  12:     using (SPWeb rootWeb = site.RootWeb)
  13:     {
  14:         Log(string.Format("Begin processing site collection '{0}'.", site.ServerRelativeUrl));
  16:         SPList relationshipList = rootWeb.Lists["Relationships List"];
  17:         SPList variationLabelsList = rootWeb.Lists["Variation Labels"];
  18:         PublishingWeb sourceLabelWeb = null;
  19:         Dictionary<PublishingWeb, bool> labelWebs = new Dictionary<PublishingWeb, bool>();
  20:         foreach (SPListItem item in variationLabelsList.Items)
  21:         {
  22:             Log(string.Format("Getting variation web '{0}'.", item["Label"]));
  24:             SPWeb web = site.OpenWeb(item["Label"].ToString());
  25:             if (!PublishingWeb.IsPublishingWeb(web))
  26:                 continue;
  28:             if ((bool) item["Is Source"])
  29:                 sourceLabelWeb = PublishingWeb.GetPublishingWeb(web);
  31:             labelWebs.Add(PublishingWeb.GetPublishingWeb(web), (bool) item["Is Source"]);
  32:         }
  33:         if (sourceLabelWeb == null)
  34:             throw new SPException("Unable to identify source label web.");
  36:         Dictionary<PublishingPage, Guid> sourcePages = new Dictionary<PublishingPage, Guid>();
  38:         Log(string.Format("Begin resetting GroupIDs."));
  40:         // First - make sure that all the matching pages have the same group ID
  41:         foreach (PublishingPage page in sourceLabelWeb.GetPublishingPages())
  42:         {
  43:             if (!(pageName == null || pageName.ToLowerInvariant() == page.Name.ToLowerInvariant()))
  44:                 continue;
  46:             Log(string.Format("Procesing page '{0}'.", page.Url));
  48:             Guid groupID = Guid.Empty;
  49:             if (page.Fields.ContainsField("PublishingVariationGroupID"))
  50:             {
  51:                 if (page.ListItem["PublishingVariationGroupID"] + "" != "" &&
  52:                     page.ListItem["PublishingVariationGroupID"] + "" != Guid.Empty.ToString())
  53:                 {
  54:                     groupID = new Guid(page.ListItem["PublishingVariationGroupID"] + "");
  55:                     Log(
  56:                         string.Format("GroupID '{0}' found in variation source '{1}'.", groupID,
  57:                                       sourceLabelWeb.Url));
  58:                 }
  59:             }
  60:             else
  61:             {
  62:                 Log(string.Format("Unable to locate PublishingVariationGroupID field for page '{0}'", pageName));
  63:             }
  64:             if (groupID == Guid.Empty)
  65:             {
  66:                 Log(string.Format("GroupID not found in source - begin searching variations."));
  67:                 // See if we can find a group ID in matching pages within the other variations
  68:                 foreach (PublishingWeb varWeb in labelWebs.Keys)
  69:                 {
  70:                     if (labelWebs[varWeb])
  71:                         continue; // Don't consider the source as we've already done that
  72:                     try
  73:                     {
  74:                         // Get the matching page
  75:                         PublishingPage varPage = varWeb.GetPublishingPages()[page.Url];
  76:                         if (varPage == null)
  77:                             continue;
  78:                         if (varPage.Fields.ContainsField("PublishingVariationGroupID"))
  79:                         {
  80:                             // If the matching page has a group ID then use that
  81:                             if (varPage.ListItem["PublishingVariationGroupID"] + "" != "")
  82:                                 groupID = new Guid(varPage.ListItem["PublishingVariationGroupID"] + "");
  83:                         }
  84:                     }
  85:                     catch (ArgumentException)
  86:                     {
  87:                     }
  88:                     if (groupID.ToString() != Guid.Empty.ToString())
  89:                     {
  90:                         Log(string.Format("GroupID '{0}' found in variation '{1}'.", groupID, varWeb.Url));
  91:                         break;
  92:                     }
  93:                 }
  94:             }
  95:             if (groupID == Guid.Empty)
  96:             {
  97:                 groupID = Guid.NewGuid();
  98:                 Log(string.Format("GroupID not found - new GroupID created: '{0}'.", groupID));
  99:             }
 101:             // Now that we have a groupID reset all pages to use that same groupID
 102:             Log(string.Format("Begin resetting variations to use new GroupID."));
 103:             foreach (PublishingWeb varWeb in labelWebs.Keys)
 104:             {
 105:                 try
 106:                 {
 107:                     // Get the matching page
 108:                     PublishingPage varPage = varWeb.GetPublishingPages()[page.Url];
 109:                     if (varPage == null)
 110:                         continue;
 111:                     if (varPage.Fields.ContainsField("PublishingVariationGroupID"))
 112:                     {
 113:                         // Set the groupID if it doesn't match what we have
 114:                         if ((varPage.ListItem["PublishingVariationGroupID"] + "").ToLower() !=
 115:                             groupID.ToString().ToLower())
 116:                         {
 117:                             Log(
 118:                                 string.Format("Assigning GroupID to page '{0}' in variation '{1}'.",
 119:                                               varPage.Url, varWeb.Url));
 121:                             varPage.ListItem["PublishingVariationGroupID"] = groupID.ToString();
 122:                             varPage.ListItem.SystemUpdate();
 123:                         }
 124:                     }
 125:                 }
 126:                 catch (ArgumentException)
 127:                 {
 128:                 }
 129:             }
 130:             Log(string.Format("Finished resetting variations to use new GroupID."));
 132:             sourcePages.Add(page, groupID);
 133:         }
 134:         // Now that all the pages have been reset to use the same group ID for each variation we can now deal with the relationships list
 135:         Log(string.Format("Finished resetting GroupIDs.\r\n"));
 136:         Log(string.Format("Begin processing of Relationships List."));
 138:         foreach (PublishingPage page in sourcePages.Keys)
 139:         {
 140:             foreach (PublishingWeb varWeb in labelWebs.Keys)
 141:             {
 142:                 Log(string.Format("Processing page '{0}' on variation '{1}'", page.Url, varWeb.Url));
 144:                 SPFieldUrlValue objectID = new SPFieldUrlValue();
 145:                 objectID.Description = varWeb.Web.ServerRelativeUrl + "/" + page.Url;
 146:                 objectID.Url = site.MakeFullUrl(objectID.Description);
 147:                 string groupID = sourcePages[page].ToString();
 149:                 SPListItem relationshipItem = null;
 150:                 foreach (SPListItem item in relationshipList.Items)
 151:                 {
 152:                     if (item["ObjectID"].ToString().ToLower() == objectID.ToString().ToLower())
 153:                     {
 154:                         Log(
 155:                             string.Format("Found item in relationships list matching ObjectID '{0}'.",
 156:                                           objectID.Description));
 158:                         relationshipItem = item;
 159:                         relationshipItem["Deleted"] = varWeb.GetPublishingPages()[page.Url] == null;
 160:                         break;
 161:                     }
 162:                 }
 163:                 if (relationshipItem == null)
 164:                 {
 165:                     Log(
 166:                         string.Format(
 167:                             "Unable to locate item in Relationships List for ObjectID '{0}' - creating a new item.",
 168:                             objectID.Description));
 169:                     // We couldn't find a matching item for the variation so we have to create one
 170:                     relationshipItem =
 171:                         relationshipList.Items.Add(relationshipList.RootFolder.ServerRelativeUrl,
 172:                                                    SPFileSystemObjectType.File);
 173:                     relationshipItem["ObjectID"] = objectID.ToString();
 174:                     relationshipItem["Deleted"] = varWeb.GetPublishingPages()[page.Url] == null;
 175:                 }
 176:                 relationshipItem["GroupID"] = groupID;
 177:                 relationshipItem.Update();
 179:                 Log(
 180:                     string.Format("Relationships List item updated - assigning link to page '{0}'.",
 181:                                   page.Url));
 183:                 // Now that the relationship list item is set the way we need it we have to link the corresponding page to the item.
 184:                 try
 185:                 {
 186:                     PublishingPage varPage = varWeb.GetPublishingPages()[page.Url];
 187:                     if (varPage == null)
 188:                         continue;
 189:                     string newLinkID = "/" +
 190:                                        (rootWeb.ServerRelativeUrl + "/" + relationshipItem.Url).Trim('/');
 191:                     newLinkID = site.MakeFullUrl(newLinkID.Replace(" ", "%20")) + ", " + newLinkID;
 193:                     varPage.ListItem["PublishingVariationRelationshipLinkFieldID"] = newLinkID;
 194:                     varPage.ListItem.SystemUpdate();
 195:                 }
 196:                 catch (ArgumentException)
 197:                 {
 198:                 }
 199:             }
 200:         }
 201:         Log(string.Format("Finished processing of Relationships List."));
 203:         foreach (PublishingWeb web in labelWebs.Keys)
 204:         {
 205:             web.Web.Dispose();
 206:         }
 207:         Log(string.Format("Finished processing site collection '{0}'.", site.ServerRelativeUrl));
 208:     }
 209: }

Using the command is very simple - you just pass in the URL of your site collection and then an optional page name if you only wish to fix a specific page.  You can also pass in an optional verbose parameter so that you can see exactly what the command is doing:

C:\>stsadm -help gl-fixvariationrelationships

stsadm -o gl-fixvariationrelationships

Links publishing pages using information in the hidden Relationship List list.

        -url <url>
        [-pagename <name of page to fix (example: "default.aspx")>]

Here's a simple example of running this command against a variation site collection:

stsadm -o gl-fixvariationrelationships -url http://portal -verbose

If you only want to affect one page you could use the following:

stsadm -o gl-fixvariationrelationships -url http://portal -verbose -pagename "default.aspx"

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: