Tuesday, October 30, 2012

How to successfully publish custom Document Set content types - Part 2: It's like butter.

(For a recap of the situation and the problems I encountered, refer to the previous post - Part 1: I got 99 problems...)

So there I was, desperately needing to set Inherits="TRUE" on my custom Doc Set content type definitions so that I could get them to publish through a Content Type Hub, and knowing that the consequence of that setting would be losing all my custom XML Documents. What to do? I had already written all the definitions in CAML/XML, and I sure didn't want to re-do all that work in C# in a feature receiver -- I had over 40 definitions! Hard-coding the construction of all of those CTypes would be a nightmare.

Well, the answer was staring me right in the face. I already had the XML. Even if I set Inherits="TRUE" and caused SharePoint to conveniently ignore all my work, it didn't actually erase my work.  SharePoint would build the CType definitions in the site level Content Type collection without using my code, but once my feature got activated, the Elements.xml files that contained my definitions would be deployed to the 14 hive.  Therefore, they would be accessible in SharePoint. And I knew all the parts would be in place by the time a feature receiver FeatureActivated() method got fired, so all I had to do would be to grab the files with my custom CType definitions, cycle through them and copy the XML Documents from the Elements.xml files into the definitions in the site.

So I started preparing an event receiver to do just that.  And I was getting ready to reach right into the 14 hive and pull my files out, but let's face it, that would be kind of ugly.  Luckily, as I was poking around the internet looking for ideas on how to pull this whole thing off, I ran across this great post by Stefan Stanev, which has one particularly beautiful (albeit long) line of LINQ.  Lo and behold, with a little tweaking, I could pull my CType definitions right out of the feature receiver's properties, and never have to touch the file system!  I was sold.

Here, now, is my event receiver.  Comments provided to help explain things step by step.

using System;
using System.Xml;
using System.Xml.Linq;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Permissions;
using Microsoft.SharePoint;
using Microsoft.SharePoint.Security;
using Microsoft.SharePoint.Utilities;
using System.Collections.Generic;
using Microsoft.SharePoint.Administration;
using System.Globalization;
using System.IO;

namespace My.ContentTypes.Features.MyCTypeDefinitions
{
    [Guid("a4cc1ae2-d1c6-4e7a-bef2-da1c26a0c96f")]
    public class MyCTypeDefinitionsEventReceiver : SPFeatureReceiver
    {
        // the id of the description field, so we can set it to HIDDEN later
        protected readonly String _docSetDescFieldID = "CBB92DA4-FD46-4C7D-AF6C-3128C2A5576E";

        public override void FeatureActivated(SPFeatureReceiverProperties properties)
        {
            using (SPSite site = properties.Feature.Parent as SPSite)
            {
                // pull all the content type definitions from our elements.xml
                // files as XElements, and put them in a list
                List<XElement> cTypeDefElements =
                    properties.Definition.GetElementDefinitions(CultureInfo.GetCultureInfo((int)site.RootWeb.Language)).Cast<SPElementDefinition>()
                    .Select(def => XElement.Parse(def.XmlDefinition.OuterXml))
                    .Where(ctdefel => ctdefel.Name.LocalName == "ContentType" && ctdefel.Attribute("ID").Value.Contains("0x0120D520"))
                    .ToList();

                // iterate through all my content type definitions
                foreach (XElement myCTypeDef in cTypeDefElements)
                {
                    // apply my XML docs to the definition on the site
                    CopyXMLDocsToSite(myCTypeDef, site);
                }

                site.RootWeb.Update();
            }
        }

        private void CopyXMLDocsToSite(XElement myCTypeDef, SPSite site)
        {
            // get all the XmlDocuments from my custom content type definition
            List<XElement> myXmlDocs = myCTypeDef.Descendants().Where(doc => doc.Parent.Name.LocalName == "XmlDocument").ToList();

            // get the actual content type from the site
            SPContentTypeId cTypeID = new SPContentTypeId(myCTypeDef.Attribute("ID").Value.ToString());
            SPContentType installedCType = site.RootWeb.ContentTypes[cTypeID];

            // get the existing XmlDocuments in the installed definition on the site
            // as XElements, and add them to a list
            List<XElement> installedDocs = installedCType.XmlDocuments.Cast<string>().Select(elem => XElement.Parse(elem)).ToList();

            // iterate through the ones we are customizing and add them
            foreach (XElement docToAdd in myXmlDocs)
            {
                // find the doc we want to replace
                XElement docToDelete = installedDocs.Find(doc => doc.Name.LocalName.Equals(docToAdd.Name.LocalName));

                // if there is one, delete it from the collection
                if (docToDelete != null)
                    installedCType.XmlDocuments.Delete(docToDelete.Name.NamespaceName);

                // if it is the receivers, insert ours into the existing "Receivers" doc
                // so we keep the default, inherited ones as well
                if (docToAdd.Name.LocalName == "Receivers" && docToDelete != null)
                {
                    AddNodesIntoExistingXElement(docToAdd, docToDelete, "Receiver");
                    SaveXElementAsXMLDocument(docToDelete, installedCType);
                }
                else
                {
                    // otherwise, just clean our definition and add it
                    CleanXElement(docToAdd);
                    SaveXElementAsXMLDocument(docToAdd, installedCType);
                }
            }

            // while we have a reference to the content type definition from the site,
            // change any other default field settings
            ChangeInheritedFieldSettings(installedCType);

            // write the changes into the database
            installedCType.Update(true);
        }

        private void AddNodesIntoExistingXElement(XElement xElementToAdd, XElement existingXElement, string nodeName)
        {
            // extract the nodes
            List<XElement> nodesToAdd = xElementToAdd.Descendants().Where(node => node.Name.LocalName == nodeName).ToList();

            // add into existing xdoc
            foreach (XElement node in nodesToAdd)
            {
                existingXElement.Add(node);
            }
        }

        private void CleanXElement(XElement xElement)
        {
            // check for attributes
            List<XAttribute> atts = null;
            if (xElement.HasAttributes)
                atts = xElement.Attributes().ToList();

            // replace the guts of the XElement using descendants
            // so we get rid of comments and only keep useful XML
            xElement.ReplaceAll(xElement.Descendants());

            // re-add the attributes if there are any
            if (atts != null)
                xElement.ReplaceAttributes(atts);
        }

        private void SaveXElementAsXMLDocument(XElement xElement, SPContentType installedCType)
        {
            // turn the XElement into an XmlDocument
            XmlDocument newDoc = new XmlDocument();
            newDoc.Load(xElement.CreateReader());

            // add it back to the content type definition on the site
            installedCType.XmlDocuments.Add(newDoc);
        }

        private void ChangeInheritedFieldSettings(SPContentType installedCType)
        {
            // get the installed content type definition schema
            // so we can access the default fields that were
            // inherited from the parent content type
            XElement cTypeSchema = XElement.Parse(installedCType.SchemaXmlWithResourceTokens);

            // set the "Document Set Description" field to be Hidden
            cTypeSchema.Descendants().Single(node => node.Name.LocalName == "FieldRef" && node.Attribute("ID")
                                     .Value.ToUpper().Contains(_docSetDescFieldID.ToUpper()))
                                     .SetAttributeValue("Hidden", "TRUE");

            /*
             *  make any other changes to inherited fields here
             */

            // save the edited schema back into the site
            installedCType.SchemaXmlWithResourceTokens = cTypeSchema.ToString(SaveOptions.DisableFormatting);
        }
    }
}


A couple more comments about what's going on there -- I had more than just the Doc Set content types in the feature, but none of the other content types had XML Documents, so I was only concerned with my custom types that derived from Doc Set. Which is why I only pull the content type defs that have an ID that contains the base ID for Doc Set. And I knew that I didn't want any default values for any of the XML Documents, which is why I delete the installed XML Documents and just replace them with mine, except for the Event Receivers, where I did want to keep the default XML Docs, so in that case I just add mine in to what's already there. Also, I had to use the ReplaceAll() method to get rid of comments that I had in my definitions, because ultimately pushing comments into the CType defs on the site did not work well. However, ReplaceAll() also got rid of the attributes, which led to a couple null reference exceptions when SharePoint choked on certain XML Docs not having the LastModified attribute.  So I had to save the attributes and re-add them after using ReplaceAll().  And finally, even though I tried to set a particular default field to be Hidden in the FieldRefs section, having Inherits="TRUE" means that SharePoint ignores that customization as well, so I had to set that attribute through code.

And with that, I have my custom XML Documents, and Inherits="TRUE", which ultimately makes publishing the content types smooth like butter.

EDIT (18 Dec 2012) : Decided to refactor the code a little bit to try and push it in a slightly more flexible, usable direction.  And for some, uh, other, personal reasons as well.  In any case, this code is not fully tested.  I did deploy and activate it once in debug to make sure it didn't choke on anything, and it ran fine.  So I'm assuming that means everything went well and if I were to publish the CTypes and then start using them, everything would work smoothly.  But, that's just an assumption.  So if you decide to borrow some of this code, just know that it has changed from when I originally wrote the post, and be ready for a bug or two.  (I'd like to hear about them if they pop up, so I can keep improving on it.)

Wednesday, October 10, 2012

How to successfully publish custom Document Set content types - Part 1: I got 99 problems...


I recently worked on a project where we had a large number of custom content types, all based on Document Set, and we needed to publish them through a Content Type Hub. Sounds simple, right? As it turns out, it's not so straightforward.

If you've ever tried to create a custom Document Set content type through code you may have run into the problem described here -- if you do what you really should do when creating a derivative content type and set Inherits="TRUE", you end up losing all your custom XML documents. For those who don't know, let's take a look at the ramifications of that little problem.  Here's a sample custom Doc Set definition the way we would want to make it:

<?xml version="1.0" encoding="utf-8" ?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
  <!-- Parent ContentType: Document Set (0x0120D520) -->
  <ContentType ID="0x0120D52000E295900502E84310BD8863E5E3468033"
               Name="My Custom Document Set"
               Group="My Custom Content Types"
               Description="A custom content type derived from Document Set"
               Inherits="TRUE"
               ProgId="SharePoint.DocumentSet"
               Version="0">

    <Folder TargetName="_cts/My Custom Document Set" />

    <FieldRefs>
      <FieldRef ID="{CBB92DA4-FD46-4C7D-AF6C-3128C2A5576E}" Name="DocumentSetDescription" Hidden="TRUE" />
      <FieldRef ID="{BCD93B9E-9DFF-4AE1-BC0E-607D3ACC9218}" Name="CustomField1" DisplayName="Custom Field 1" Required="TRUE" />
      <FieldRef ID="{6692F1B7-8087-4E65-B509-C819D694FAED}" Name="CustomField2" DisplayName="Custom Field 2" Required="FALSE"/>
      <FieldRef ID="{7EC6256B-F521-4D43-B346-FE010478DDCF}" Name="CustomDateField" DisplayName="Custom Date Field" Required="TRUE" Format="DateOnly" />
    </FieldRefs>

    <XmlDocuments>

      <!-- List of all fields [site columns] shared between all content types and the document set. -->
      <XmlDocument NamespaceURI="http://schemas.microsoft.com/office/documentsets/sharedfields">
        <sf:SharedFields xmlns:sf="http://schemas.microsoft.com/office/documentsets/sharedfields" LastModified="1/1/2010 08:00:00 AM">
          <!-- Add shared fields here using the syntax below-->
          <!--<SharedField id="00000000-0000-0000-0000-000000000000" />-->
          <SharedField id="BCD93B9E-9DFF-4AE1-BC0E-607D3ACC9218" Name="CustomField1" />
          <SharedField id="6692F1B7-8087-4E65-B509-C819D694FAED" Name="CustomField2" />
          <SharedField id="7EC6256B-F521-4D43-B346-FE010478DDCF" Name="CustomDateField" />
        </sf:SharedFields>
      </XmlDocument>

      <!-- Add Event Receivers-->
      <XmlDocument NamespaceURI="http://schemas.microsoft.com/sharepoint/events">
        <Receivers xmlns:spe="http://schemas.microsoft.com/sharepoint/events">
          <Receiver>
            <Name>MyCustomItemAddedEventReceiver</Name>
            <Type>ItemAdded</Type>
            <SequenceNumber>10001</SequenceNumber>
            <Assembly>MyCustomCode.MyEventReceivers, Version=1.0.0.0, Culture=neutral, PublicKeyToken=756a9f17c5fe9a3b</Assembly>
            <Class>MyCustomCode.MyEventReceivers.ItemAddedEventReceiver.ItemAddedEventReceiver</Class>
            <Data></Data>
            <Filter></Filter>
          </Receiver>
        </Receivers>
      </XmlDocument>

      <!-- List of all content types that are allowed in the document set. -->
      <XmlDocument NamespaceURI="http://schemas.microsoft.com/office/documentsets/allowedcontenttypes">
        <act:AllowedContentTypes xmlns:act="http://schemas.microsoft.com/office/documentsets/allowedcontenttypes" LastModified="1/1/2010 08:00:00 AM">
          <!-- Add content types that will be used in the document set using the syntax below -->
          <!--<AllowedContentType id="00000000-0000-0000-0000-000000000000" />-->
          <AllowedContentType id="0x0101"/>
          <AllowedContentType id="0x01010069928169381543AC927453E7A865E300"/>
        </act:AllowedContentTypes>
      </XmlDocument>

      <!-- List of all fields [site columns] that should appear on welcome page. -->
      <XmlDocument NamespaceURI="http://schemas.microsoft.com/office/documentsets/welcomepagefields">
        <wpFields:WelcomePageFields xmlns:wpFields="http://schemas.microsoft.com/office/documentsets/welcomepagefields" LastModified="1/1/2010 08:00:00 AM">
          <!-- Add welcome fields here using the syntax below -->
          <!--<WelcomePageField id="00000000-0000-0000-0000-000000000000" />-->      
          <WelcomePageField id="BCD93B9E-9DFF-4AE1-BC0E-607D3ACC9218" Name="CustomField1" />
          <WelcomePageField id="6692F1B7-8087-4E65-B509-C819D694FAED" Name="CustomField2" />
          <WelcomePageField id="7EC6256B-F521-4D43-B346-FE010478DDCF" Name="CustomDateField" />
        </wpFields:WelcomePageFields>
      </XmlDocument>

      <!-- List of all default documents associated with the content types. -->
      <XmlDocument NamespaceURI="http://schemas.microsoft.com/office/documentsets/defaultdocuments">
        <dd:DefaultDocuments xmlns:dd="http://schemas.microsoft.com/office/documentsets/defaultdocuments" AddSetName="TRUE" LastModified="1/1/2010 08:00:00 AM">
          <!-- Add default documents using the syntax below -->
          <!--<DefaultDocument name="Sample Document.docx" idContentType="0x0101" />-->
        </dd:DefaultDocuments>
      </XmlDocument>

      <!-- Documents used (OOB) -->
      <XmlDocument NamespaceURI="http://schemas.microsoft.com/sharepoint/v3/contenttype/forms/url">
        <FormUrls xmlns="http://schemas.microsoft.com/sharepoint/v3/contenttype/forms/url">
          <New>_layouts/NewDocSet.aspx</New>
        </FormUrls>
      </XmlDocument>

      <XmlDocument NamespaceURI="http://schemas.microsoft.com/sharepoint/v3/contenttype/forms">
        <FormTemplates  xmlns="http://schemas.microsoft.com/sharepoint/v3/contenttype/forms">
          <Display>ListForm</Display>
          <Edit>ListForm</Edit>
          <New>DocSetDisplayForm</New>
        </FormTemplates>
      </XmlDocument>
    </XmlDocuments>
  </ContentType>
</Elements>

Now take a look at the XML Documents section... it's huge! If we lose our customization there, we lose the key things that make a Doc Set a Doc Set: the shared fields, the welcome page fields, the default documents, etc. Plus, if we have any custom event receivers or forms, we lose those too. That's quite a lot to lose for trying to do things correctly.

It would seem that the easy solution is to just set Inherits="FALSE", which means you then have to remember to explicitly add into the definition all the stuff you should be inheriting, like the default Doc Set event receivers.  (Which, truth be told, I did not do, and because of that ended up stumbling into my solution for all these problems, which I will describe in the next post.)  But at least all your customization remains intact. And indeed that does work if you are only deploying to one site. But what if you need to syndicate your Doc Sets through content type publishing? Well, then you might run into another problem, which shows up in the content type publishing error logs on the subscribing sites after the publishing doesn't work:
Content type 'My Custom Document Set' cannot be published to this site because feature 'MyContentTypeDefinitionsFeature' is not enabled.
Wait, what? I need to enable the feature that has my content type definitions on the site I'm trying to publish to? If I need to do that, why am I even bothering with the publishing? When I first got this error, I was left scratching my head a bit, but went ahead and obliged SharePoint and enabled the feature.  And then the publishing worked... sometimes.  And sometimes it wouldn't, and there seemed to be no rhyme or reason to when it would and when it wouldn't.  The content type publishing error logs only reported an "unknown error."  So I started digging a little deeper and cracked open the ULS logs, where I found this error:
Unable to locate the xml-definition for CType with SPContentTypeId <Content Type ID>
And to top that off, in the cases where the publishing would actually succeed, I started getting yet another problem -- anytime anyone would try to create a new Doc Set based on one of our custom types, it would fail with an error stating that the content type definition was read-only. Well of course it's read-only, it's been syndicated, that's exactly what's supposed to happen! But why is that causing a problem? If I'm getting a read-only error, that implies that SharePoint is trying to modify the content type definition itself.  Why?

I then proceeded to google the google out of Google.

After a while, I finally found a few things that helped me paint a picture of what was happening.  A very speculative picture, no doubt. I won't claim that what I thought I understood about the problems is anywhere near accurate. But, in some strange way, it made sense to me, and eventually led me to figuring out a real solution to the problem.

First, I found this question and answer on StackExchange about needing to enable the feature that held the CType definitions when trying to publish. Someone was having the same problem I was, and the answer made sense to me.  Because I had set Inherits="FALSE", SharePoint couldn't pull the XML Schema from the original definition on the Hub (or from the parent CType), but it did know what feature held the Schema, so it demanded that the feature be activated so it could access the Schema there.

Then I found this blog post about the "unable to locate the xml-definition" error in the ULS logs. It doesn't offer much information about the problem itself, but it does have one little gem that I latched on to -- the author has "seen these errors happen in environments where deployed package [sic] was deployed before." In a certain sense, during the publishing process, SharePoint is trying to deploy a content type definition to a subscribing site by pulling it from the Hub.  However, to get past the previous error, we've enabled the feature containing the content type definition on the subscribing site already, and therefore have "deployed the package before," so to speak. Now, maybe what's happening is not that SharePoint can't find the XML definition for the content type -- it's actually finding two, and can't decide which is the right one to use.

The final piece of the puzzle (third strike? last straw?) came when I cracked open a reflector and started to trace through what happens when a new Doc Set is created.  As it turns out, as part of the creation process, the DocumentSet.Provision() method is called, which will cause SharePoint to check to see if the content type definition has the default Doc Set event receivers.  If it doesn't, even though you are technically creating the Doc Set based off a list level CType definition, SharePoint will reach up to the parent definition at the site level and try to provision the event receivers there. And, since my site level definitions were read-only because they were syndicated through content type publishing... I would get a read-only error. Because (as I said earlier) I had neglected to include the default Doc Set event receivers when I originally set Inherits="FALSE".

So what it all boiled down to at this point was that I absolutely had to get things working with Inherits="TRUE". It would give me the default Doc Set event receivers which would get rid of my read-only error.  And most importantly, it would allow SharePoint to know where to pull the Schema from and enable successful publishing, while only activating the feature on the one site it should be activated on - the Content Type Hub. But...what to do about inevitably losing the custom XML Documents? I had already written all that CAML, I really didn't want to have to write some crazy huge feature receiver just to re-do all that work in code.

As it turned out, the answer was right in front of my face.

To read about what it was, and how I got everything working, stay tuned for part 2...


Wednesday, August 29, 2012

How to hide top navigation bar links without using Audiences.

EDIT 11/2013: I have come to think that this is just a bad idea.  It works, don't get me wrong, and it may work in your situation (see my many caveats further down in the post), but it's very difficult to maintain / manage, and has the potential to render an entire site useless, if the code starts throwing exceptions.  My plan is to try and do something similar conceptually, but do it all through JSOM in a script file.  That way if the code suffers catastrophic failure, the master page will still load and the site will still be accessible.  In any case, I have left everything as originally posted, for your entertainment.  Proceed at your own risk.

Here is a little technique you can use to hide top navigation bar links from users, based on their membership in SharePoint groups. You won't have to use the User Profile Service to set up Audiences, and you won't have to use the Publishing Infrastructure feature to access the navigation nodes (although you don't really need it anyway), so it may work on SharePoint Foundation as well as Server. (I haven't tested it on Foundation so I'm not positive about that.)

You will, however, have to edit your master page. If you are new to SharePoint, remember that a best practice is to not edit the out-of-the-box master page. Even if you don't currently have a custom master page, to use this technique you should at least make a copy of v4.master and use the copy instead of the original, because you're about to make your master page custom.

Here are a few resources on how to make a copy of v4.master and assign your copy as the new master page in SharePoint:

The idea behind this is pretty simple: we can check a user's group membership using server-side code while the page is still being constructed, but at that point we can't really mess with the top navigation bar.  SharePoint is going to do its thing and load the links in the top nav bar as they are defined in the SPWeb.Navigation.TopNavigationBar node collection on the web, whether we like it or not. But, we can manipulate DOM elements on the client side after the page is rendered. So what we have to do is pass the user authorization information we get on the server side to the client, where we can then act on that information.

The first step in getting all this to work is to include jQuery in your master page so we can use it to remove the links once the page is loaded. Here are a few resources on how to include jQuery on your master page:

fitandfinish.ironworks.com
blogs.msdn.com/b/publicsector
threewill.com

Now that' we've got the jQuery ready, let's look at some code. This should all be included just at the end of the <head> section of the master page.


<script runat="server" type="text/C#"> 
    private Control FindControlRecursive(Control control, string id)
    {
        Control returnControl = control.FindControl(id);
        if (returnControl == null)
        {
            foreach (Control child in control.Controls)
            {
                returnControl = FindControlRecursive(child, id);
                if (returnControl != null && returnControl.ID == id)
                {
                    return returnControl;
                }
            }
        }
        return returnControl;
    }

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);

        bool isGroupOneUser = false;
        bool isGroupTwoUser = false;

        try
        {
            int groupOneID = SPContext.Current.Web.Groups["Group One Users"].ID;
            isGroupOneUser = SPContext.Current.Web.IsCurrentUserMemberOfGroup(groupOneID);
        }
        catch (Exception ex)
        {
            if (ex.Message.Contains("Group cannot be found")) { /* do nothing */ }
            else
                throw;
        }

        try
        {
            int groupTwoID = SPContext.Current.Web.Groups["Group Two Users"].ID;
            isGroupTwoUser = SPContext.Current.Web.IsCurrentUserMemberOfGroup(groupTwoID);
        }
        catch (Exception ex)
        {
            if (ex.Message.Contains("Group cannot be found")) { /* do nothing */ }
            else
                throw;
        }

        if (SPContext.Current.Web.CurrentUser.IsSiteAdmin)
        {
            isGroupOneUser = true;
            isGroupTwoUser = true;
        }

        HiddenField groupOneField = new HiddenField();
        groupOneField.Value = isGroupOneUser.ToString().ToUpper();
        groupOneField.ID = "isGroupOneUser";

        HiddenField groupTwoField = new HiddenField();
        groupTwoField.Value = isGroupTwoUser.ToString().ToUpper();
        groupTwoField.ID = "isGroupTwoUser";

        Control topNavBarArea = FindControlRecursive(this, "PlaceHolderTopNavBar");

        topNavBarArea.Controls.Add(groupOneField);
        topNavBarArea.Controls.Add(groupTwoField);
    }
</script>
<script type="text/javascript">
    $(document).ready(function () {
        if ($('input[id*="isGroupOneUser"]').val() == "FALSE") {
            $('span:contains("Link One Title")').closest('li').hide();
        }
        if ($('input[id*="isGroupTwoUser"]').val() == "FALSE") {
            $('span:contains("Link Two Title")').closest('li').hide();
        }
    });
</script>


Did you get all that? No? Ok, let's break it down...

There are two script sections, one is C# code to run server side, and one is jQuery to run client side.  In the server-side section, the first method, FindControlRecursive() is a neat little method I found on StackExchange. It gets used later on when we want to embed a few hidden controls in the page.  The second method is the override of OnLoad(), and that's where we get to work.

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);

        bool isGroupOneUser = false;
        bool isGroupTwoUser = false;

First, let the default OnLoad() run its course.  Then, define some boolean variables that will hold the results of the authorization checks.  Because the restricted links should only be revealed if the user is a member of the appropriate group, set these to false initially, in order to prevent anyone from seeing something they shouldn't if there happens to be an error when performing the authorization check.

        try
        {
            int groupOneID = SPContext.Current.Web.Groups["Group One Users"].ID;
            isGroupOneUser = SPContext.Current.Web.IsCurrentUserMemberOfGroup(groupOneID);
        }
        catch (Exception ex)
        {
            if (ex.Message.Contains("Group cannot be found")) { /* do nothing */ }
            else
                throw;
        }


Next, check whether the current user is a member of a specific group.  This is done inside a try / catch block in case the group doesn't exist on the site.  Remember, this is the master page, so if we throw an error here we break the entire site, because no pages will be able to load.  Inside the catch block, check to see if it's the particular error of the group not being found on the site.  If that's the case, I've chosen to do nothing, because I want the restricted link to remain hidden even if the group does not exist.  But, you could put isGroupOneUser = true; instead, which would effectively reveal the link to everyone if the group did not exist.  If the error thrown is something other than the group not being found, we want to know about it, so re-throw any other exceptions.

        if (SPContext.Current.Web.CurrentUser.IsSiteAdmin)
        {
            isGroupOneUser = true;
            isGroupTwoUser = true;
        }

Lastly, if the user happens to be a site collection administrator, set all the booleans to true no matter what they were set to before, which will reveal all restricted links regardless of whether or not the user belongs to the appropriate groups.  After all, if they're a site collection admin, they should be able to see what's going on with the site, no?

Now that we have our authorization information for the current user, we have to send the information to the client so we can use it.  We do this by embedding some new controls in the page.

        HiddenField groupOneField = new HiddenField();
        groupOneField.Value = isGroupOneUser.ToString().ToUpper();
        groupOneField.ID = "isGroupOneUser";

First make a new hidden field.  Set the value of the field to be the result of the authorization check.  I chose to standardize the result as upper case because later we will be doing a string comparison (since a hidden field stores a string, and not a true boolean), so I want to be sure I know exactly what I'm looking for.  Then set the ID of the field to something unique and meaningful, so jQuery can easily find it later.

        Control topNavBarArea = FindControlRecursive(this, "PlaceHolderTopNavBar");

        topNavBarArea.Controls.Add(groupOneField);
        topNavBarArea.Controls.Add(groupTwoField);

Now that the hidden fields are set with the information we want to pass to the client, use the FindControlRecursive() method to find an appropriate control on the page to add them. In the interest of sanity and organization, since these controls do have to do with manipulating the top nav bar, I chose to add them right to the place where SharePoint is going to add the top nav bar. You can add them anywhere though, just look around on the page and choose a control. Once the control is found, add the hidden fields, and the hand-off is made -- on to the client!

    $(document).ready(function () {
        if ($('input[id*="isGroupOneUser"]').val() == "FALSE") {
            $('span:contains("Link One Title")').closest('li').hide();
        }

In typical jQuery fashion, start by waiting until the page is fully loaded before doing anything. Then, find the hidden field using the ID value set earlier. It might have been possible to use $('#<%=isGroupOneUser.ClientID%>') to find the hidden field, but I decided to go with the attribute contains selector instead, since I wasn't sure when the client ID substitution would happen relative to when the hidden fields were actually added to the page. (I would hate to be trying to substitute the ClientID property of a control that didn't exist yet...) So, find the input control who's ID attribute contains exactly what it was set to in the server side code, and see if the value is false. If so, find the link that should be hidden by finding the span that contains the text visible on the link. Then, traverse up the DOM tree to the closest (in this case, containing) list item element, and hide that element.

Now at this point you might be asking yourself "where the heck did these <span> and <li> elements come from? I want to hide a nav bar link, so shouldn't I be looking for an <a> element?" Well, if we take a look at how SharePoint actually renders the top nav bar controls, you'll see that there is a method to my madness:

<div id="zz16_TopNavigationMenuV4" class="s4-tn">
    <div class="menu horizontal menu-horizontal">
        <ul class="root static">
            <li class="static">
                <a class="static menu-item" href="/url/to/link_one.aspx">
                    <span class="additional-background">
                        <span class="menu-item-text">Link One Title</span>
                    </span>
                </a>
            </li>
            <li class="static">
                <a class="static menu-item" href="/url/to/link_two.aspx">
                    <span class="additional-background">
                        <span class="menu-item-text">Link Two Title</span>
                    </span>
                </a>
            </li>
        </ul>
    </div>
</div>

So as you can see, the <a> element is only a part of what makes up an entire top nav bar link construction. And since none of the elements rendered have ID or name attributes we can hook into to find the correct link with any assurance, I went with finding the text that is the name of the link itself, since I definitely know what that's going to be, because I put them there and named them myself. And even if you weren't the one to create the links in your scenario, you certainly know what they're named just by looking at the top nav bar. So after I find the <span> that contains the specific text that I'm looking for, I traverse up the DOM tree to the highest element that forms a top nav bar link, which happens to be a <li> element. Then I hide that element, in order to hide the whole construction.

And it works like a charm -- all the other links slide over so there's no gaps or odd spacing, everything stays ril ril purty.

Keep in mind though, this technique may not work well in every scenario, so YMMV. In my case, it was a very stable environment, meaning: we built it once and that's the way it's going to stay.  There's one master page used everywhere, there are not going to be a lot of team collaboration sites (or any sites, for that matter) being created or deleted by the end users as they need, and no one is going to be adding or removing top nav bar links willy-nilly.  If there's a lot of dynamism to your environment and these links are going to come and go, or if you have multiple different master pages, this may not be the right technique for you, since any changes that have to be made to the behavior of hiding the links has to be made on the master page itself, which could translate into a lot of administrative overhead. But if you only have one master page, and the links you need in the top nav bar are likely not to change very often, this might work for you.