Last month I created a couple pretty basic commands to help me with setting the navigational elements of my site: gl-enumnavigation and gl-addnavigationnode. You can find information about those commands here: Site Navigation Settings.

As I worked more on my upgraded site I found that what I really needed was a way to make drastic changes using an XML file rather than trying to add or remove one item at a time (note that I haven’t created anything to allow removing a single item but I suspect after what I’ve just done that wouldn’t be too hard). To address my needs I first needed to modify my original gl-enumnavigation command so that it could output XML instead of the flat list that it previously did (probably should have done that to begin with but…).

This was pretty easy to do except for one stumbling block – you’d think it would be easy to determine whether a site or page was hidden and that this info would be part of the SPNavigationNode object – unfortunately that’s not the case – it took me a bit of digging to realize that I had to “find” the correct PublishingWeb or PublishingPage object and then checks it’s IncludeInGlobalNavigation or IncludeInCurrentNavigation properties.

The second thing I needed to do was to create a new command which would be able to take the generated XML from the gl-enumnavigation command and use it to “rebuild” the navigation. This works pretty well in the sense that you can export using gl-enumnavigation, modify the generated XML to meet whatever custom needs you have, and then import using the gl-setnavigationnodes command that I created.

For my purposes this has enabled me to do a test upgrade – work the navigation to how by business users stipulated, save that out to a file and then re-run my upgrade any number of times and simple reset the navigation using the previously generated file. Another use could be to help get around the fact that there’s no approval process for navigation changes – an administrator could make the proposed changes in a test site (either in a test farm or on the same production farm), get stakeholder approval, and then import the changes to the production site (and gl-enumnavigation could be used to make a backup of the existing navigation in the event that it becomes necessary to roll-back).

Once I had these two commands created I realized that it wasn’t a big step to create a copy command. So I created gl-copynavigation which really just combines gl-enumnavigation and gl-setnavigationnodes but doesn’t require you to deal with creating the file (though it does have an option to backup the target in case you want to revert the site). The two commands are detailed below.

gl-setnavigationnodes

I’d like to say that this was real easy to do – it should have been – but as it turned out it was much more difficult than I thought it would be (I’m beginning to detect a trend). I have two main methods which do all the work – the first, SetNavigation() does all the prep and cleanup work; the second, AddNodes() actually does the adding of the nodes to the appropriate collection. The real difficult part of all this was that what you see in the browser is not what you get when you query the GlobalNavigationNodes and CurrentNavigationNodes collections. These collections will not show the various sub-sites and pages that show up in the navigation unless you’ve explicitly set some property (by moving a node for example). So I had to take look in more than once place for everything and the logic of it all is really bizarre. The code is well documented so I won’t go through it again here:

  1/// <summary>
  2/// Sets the navigation.
  3/// </summary>
  4/// <param name="web">The web site.</param>
  5/// <param name="xmlDoc">The XML doc containing the navigation nodes.</param>
  6/// <param name="showSubSites">if set to <c>true</c> [show sub sites].</param>
  7/// <param name="showPages">if set to <c>true</c> [show pages].</param>
  8/// <param name="deleteExistingGlobal">if set to <c>true</c> [delete existing global nodes].</param>
  9/// <param name="deleteExistingCurrent">if set to <c>true</c> [delete existing current nodes].</param>
 10public static void SetNavigation(SPWeb web, XmlDocument xmlDoc, bool showSubSites, bool showPages, bool deleteExistingGlobal, bool deleteExistingCurrent)
 11{
 12    PublishingWeb pubweb = PublishingWeb.GetPublishingWeb(web);
 13 
 14    // First need to set whether or not we show sub-sites and pages
 15    pubweb.IncludeSubSitesInNavigation = showSubSites;
 16    pubweb.IncludePagesInNavigation = showPages;
 17    pubweb.Update();
 18 
 19    List<SPNavigationNode> existingGlobalNodes = new List<SPNavigationNode>();
 20    List<SPNavigationNode> existingCurrentNodes = new List<SPNavigationNode>();
 21    // We can't delete the navigation items until we've added the new ones so store the existing 
 22    // ones for later deletion (note that we don't have to store all of them - just the top level).
 23    // I have no idea why this is the case - but when I tried to clear everything out first I got
 24    // all kinds of funky errors that just made no sense to me - this works so....
 25    foreach (SPNavigationNode node in pubweb.GlobalNavigationNodes)
 26        existingGlobalNodes.Add(node);
 27    foreach (SPNavigationNode node in pubweb.CurrentNavigationNodes)
 28        existingCurrentNodes.Add(node);
 29 
 30    XmlNodeList newGlobalNodes = xmlDoc.SelectNodes("//Navigation/Global/Node");
 31    XmlNodeList newCurrentNodes = xmlDoc.SelectNodes("//Navigation/Current/Node");
 32 
 33 
 34    // If we've got global or current nodes in the xml then the intent is to reset those elements.
 35    // If we've also specified to delete any existing elements then we need to first hide all the
 36    // sub-sites and pages (you can't delete them because they don't exist as a node).  Note that
 37    // we are only doing this if showSubSites is true - if it's false we don't see them so no point
 38    // in hiding them.  Any non-sub-site or non-page will be deleted after we've added the new nodes.
 39    if (newGlobalNodes.Count > 0 && deleteExistingGlobal && showSubSites)
 40    {
 41        // Initialize the sub-sites (forces the provided XML to specify whether any should be visible)
 42        foreach (SPWeb tempWeb in pubweb.Web.Webs)
 43        {
 44            pubweb.ExcludeFromNavigation(true, tempWeb.ID);
 45        }
 46        pubweb.Update();
 47    }
 48    if (newCurrentNodes.Count > 0 && deleteExistingCurrent && showSubSites)
 49    {
 50        foreach (SPWeb tempWeb in pubweb.Web.Webs)
 51        {
 52            pubweb.ExcludeFromNavigation(false, tempWeb.ID);
 53        }
 54        pubweb.Update();
 55    }
 56 
 57    // Now we need to add all the global nodes (if any - if the collection is empty the following will just return and do nothing)
 58    AddNodes(pubweb, true, pubweb.GlobalNavigationNodes, newGlobalNodes);
 59    // Update the web as the above may have made modifications
 60    pubweb.Update();
 61 
 62    // Now delete all the previously existing global nodes.
 63    if (newGlobalNodes.Count > 0 && deleteExistingGlobal)
 64    {
 65        foreach (SPNavigationNode node in existingGlobalNodes)
 66        {
 67            node.Delete();
 68        }
 69    }
 70 
 71    // Now we need to add all the current nodes (if any)
 72    AddNodes(pubweb, false, pubweb.CurrentNavigationNodes, newCurrentNodes);
 73    // Update the web as the above may have made modifications
 74    pubweb.Update();
 75 
 76    // Now delete all the previously existing current nodes.
 77    if (newCurrentNodes.Count > 0 && deleteExistingCurrent)
 78    {
 79        foreach (SPNavigationNode node in existingCurrentNodes)
 80        {
 81            node.Delete();
 82        }
 83    }
 84}
 85 
 86 
 87/// <summary>
 88/// Adds the nodes.
 89/// </summary>
 90/// <param name="pubWeb">The publishing web.</param>
 91/// <param name="isGlobal">if set to <c>true</c> [is global].</param>
 92/// <param name="existingNodes">The existing nodes.</param>
 93/// <param name="newNodes">The new nodes.</param>
 94private static void AddNodes(PublishingWeb pubWeb, bool isGlobal, SPNavigationNodeCollection existingNodes, XmlNodeList newNodes)
 95{
 96    if (newNodes.Count == 0)
 97        return;
 98 
 99    for (int i = 0; i < newNodes.Count; i++)
100    {
101        XmlElement newNodeXml = (XmlElement)newNodes[i];
102        string url = newNodeXml.SelectSingleNode("Url").InnerText;
103        string title = newNodeXml.GetAttribute("Title");
104        NodeTypes type = NodeTypes.None;
105        if (newNodeXml.SelectSingleNode("NodeType") != null && !string.IsNullOrEmpty(newNodeXml.SelectSingleNode("NodeType").InnerText))
106            type = (NodeTypes)Enum.Parse(typeof(NodeTypes), newNodeXml.SelectSingleNode("NodeType").InnerText);
107 
108        bool isVisible = true;
109        if (!string.IsNullOrEmpty(newNodeXml.GetAttribute("IsVisible")))
110            isVisible = bool.Parse(newNodeXml.GetAttribute("IsVisible"));
111 
112        if (type == NodeTypes.Area)
113        {
114            // You can't just add an "Area" node (which represents a sub-site) to the current web if the
115            // url does not correspond with an actual sub-site (the code will appear to work but you won't
116            // see anything when you load the page).  So we need to check and see if the node actually
117            // points to a sub-site - if it does not then change it to "AuthoredLinkToWeb".
118            SPWeb web = null;
119            string name = url.Trim('/');
120            if (name.Length != 0 && name.IndexOf("/") > 0)
121            {
122                name = name.Substring(name.LastIndexOf('/') + 1);
123            }
124            try
125            {
126                // Note that pubWeb.Web.Webs[] does not return null if the item doesn't exist - it simply throws an exception (I hate that!)
127                web = pubWeb.Web.Webs[name];
128            }
129            catch (ArgumentException)
130            {
131            }
132            if (web == null || !web.Exists || web.ServerRelativeUrl.ToLower() != url.ToLower())
133            {
134                // The url doesn't correspond with a sub-site for the current web so change the node type.
135                // This is most likely due to copying navigation elements from another site
136                type = NodeTypes.AuthoredLinkToWeb;
137            }
138            else if (web.Exists && web.ServerRelativeUrl.ToLower() == url.ToLower())
139            {
140                // We did find a matching sub-site so now we need to set the visibility
141                if (isVisible)
142                    pubWeb.IncludeInNavigation(isGlobal, web.ID);
143                else
144                    pubWeb.ExcludeFromNavigation(isGlobal, web.ID);
145            }
146 
147        }
148        else if (type == NodeTypes.Page)
149        {
150            // Adding links to pages has the same limitation as sub-sites (Area nodes) so we need to make
151            // sure it actually exists and if it doesn't then change the node type.
152            PublishingPage page = null;
153            try
154            {
155                // Note that GetPublishingPages()[] does not return null if the item doesn't exist - it simply throws an exception (I hate that!)
156                page = pubWeb.GetPublishingPages()[url];
157            }
158            catch (ArgumentException)
159            {
160            }
161            if (page == null)
162            {
163                // The url doesn't correspond with a page for the current web so change the node type.
164                // This is most likely due to copying navigation elements from another site
165                type = NodeTypes.AuthoredLinkToPage;
166                url = pubWeb.Web.Site.MakeFullUrl(url);
167            }
168            else
169            {
170                // We did find a matching page so now we need to set the visibility
171                if (isVisible)
172                    pubWeb.IncludeInNavigation(isGlobal, page.ListItem.UniqueId);
173                else
174                    pubWeb.ExcludeFromNavigation(isGlobal, page.ListItem.UniqueId);
175            }
176        }
177 
178        // If it's not a sub-site or a page that's part of the current web and it's set to
179        // not be visible then just move on to the next (there is no visibility setting for
180        // nodes that are not of type Area or Page).
181        if (!isVisible && type != NodeTypes.Area && type != NodeTypes.Page)
182            continue;
183 
184        // Finally, can add the node to the collection.
185        SPNavigationNode node = SPNavigationSiteMapNode.CreateSPNavigationNode(
186            title, url, type, existingNodes);
187 
188       
189        // Now we need to set all the other properties
190        foreach (XmlElement property in newNodeXml.ChildNodes)
191        {
192            // We've already set these so don't set them again.
193            if (property.Name == "Url" || property.Name == "Node" || property.Name == "NodeType")
194                continue;
195 
196            // CreatedDate and LastModifiedDate need to be the correct type - all other properties are strings
197            if (property.Name == "CreatedDate" && !string.IsNullOrEmpty(property.InnerText))
198            {
199                node.Properties["CreatedDate"] = DateTime.Parse(property.InnerText);
200                continue;
201            }
202            if (property.Name == "LastModifiedDate" && !string.IsNullOrEmpty(property.InnerText))
203            {
204                node.Properties["LastModifiedDate"] = DateTime.Parse(property.InnerText);
205                continue;
206            }
207 
208            node.Properties[property.Name] = property.InnerText;
209        }
210        // If we didn't have a CreatedDate or LastModifiedDate then set them to now.
211        if (node.Properties["CreatedDate"] == null)
212            node.Properties["CreatedDate"] = DateTime.Now;
213        if (node.Properties["LastModifiedDate"] == null)
214            node.Properties["LastModifiedDate"] = DateTime.Now;
215 
216        // Save our changes to the node.
217        node.Update();
218        node.MoveToLast(existingNodes); // Should already be at the end but I prefer to make sure 🙂
219        
220        XmlNodeList childNodes = newNodeXml.SelectNodes("Node");
221 
222        // If we have child nodes then make a recursive call passing in the current nodes Children property as the collection to add to.
223        if (childNodes.Count > 0)
224            AddNodes(pubWeb, isGlobal, node.Children, childNodes);
225    }
226}

The syntax of the command can be seen below:

C:\>stsadm -help gl-setnavigationnodes

stsadm -o gl-setnavigationnodes

Rebuilds the Global and/or Current navigation based on a passed in XML file which can be generated by the gl-enumnavigation command and then modified (note that the Id attribute is ignored).

Parameters:
        -url <site collection url>
        -inputfile <xml file to use as input>
        [-showsubsites <true (default) | false>]
        [-showpages <true | false (default)>]
        [-deleteexistingglobal <true (default) | false>]
        [-deleteexistingcurrent <true (default) | false>]
        [-backuptarget <filename>]

Here’s an example of how to set the navigation replacing both the global (Top Nav) and current (Quick Launch) navigation with that specified in the input file “input.xml”:

stsadm –o gl-setnavigationnodes –url "http://intranet/hr" -inputfile "c:\input.xml" -backuptarget "c:\backup.xml"

If you want to do more of a merge (add everything from the XML file to the end of the navigation list) you would do the following:

stsadm –o gl-setnavigationnodes –url "http://intranet/hr" -inputfile "c:\input.xml" -backuptarget "c:\backup.xml" -deleteexistingglobal false -deleteexistingcurrent false

Doing the above would be useful if you’ve got a certain set of navigational elements that you want to have on all your site collections – you could define the XML file once and then use setnavigationnodes to add those elements to each site collection without having to do it manually.

Update 10/15/2007: I enhanced this command slightly. If you add the XML tag <AutoAddSubSites /> to the source XML the code will replace this with nodes corresponding to all the sub-sites for the target web. This is useful if you’ve got a global navigation that you want to propagate to various site collections but you want the site collections sub-sites listed in addition to what you are importing. In my case I added the following XML to my source so that all sub-sites would appear under a heading called “Sub Sites”:

 1<Node Title="Sub Sites" IsVisible="True">
 2    <Url>/</Url>
 3    <UrlFragment></UrlFragment>
 4    <vti_navsequencechild>true</vti_navsequencechild>
 5    <CreatedDate>10/15/2007 6:28:01 PM</CreatedDate>
 6    <UrlQueryString></UrlQueryString>
 7    <NodeType>Heading</NodeType>
 8    <Audience></Audience>
 9    <Target></Target>
10    <Description></Description>
11    <LastModifiedDate>10/15/2007 6:28:01 PM</LastModifiedDate>
12    <BlankUrl>True</BlankUrl>
13    <AutoAddSubSites />
14</Node>

gl-copynavigation

I basically got this command for free – all I’m doing is utilizing what I’d already done for gl-enumnavigation and gl-setnavigationnodes. I haven’t actually needed to use this myself but I thought someone might find it useful and as it was simple to create (which has been a nice change) I didn’t really spend much time on it. Here’s the core of the code:

 1XmlDocument xmlDoc;
 2// Get the XML from our source site.
 3using (SPSite sourceSite = new SPSite(sourceurl))
 4using (SPWeb sourceWeb = sourceSite.OpenWeb())
 5{
 6    PublishingWeb sourcePubWeb = PublishingWeb.GetPublishingWeb(sourceWeb);
 7    xmlDoc = EnumNavigation.GetNavigationAsXml(sourcePubWeb);
 8}
 9 
10// If the user only wants to copy the global or current then remove the other element
11if (currentOnly)
12    xmlDoc.SelectSingleNode("//Navigation/Global").RemoveAll();
13if (globalOnly)
14    xmlDoc.SelectSingleNode("//Navigation/Current").RemoveAll();
15 
16// Set the navigation using the XML we got from the source.
17using (SPSite targetSite = new SPSite(targeturl))
18using (SPWeb targetWeb = targetSite.OpenWeb())
19{
20    if (keyValues.ContainsKey("backuptarget"))
21    {
22        PublishingWeb targetPubWeb = PublishingWeb.GetPublishingWeb(targetWeb);
23        XmlDocument xmlBackupDoc = EnumNavigation.GetNavigationAsXml(targetPubWeb);
24        xmlBackupDoc.Save(keyValues["backuptarget"]);
25    }
26    SetNavigationNodes.SetNavigation(targetWeb, xmlDoc, showSubSites, showPages, deleteExistingGlobal, deleteExistingCurrent);
27}

As you can see from the above – it’s really quite simple. The syntax of the command can be seen below:

C:\>stsadm -help gl-copynavigation

stsadm -o gl-copynavigation

Copies the Global and/or Current navigation from one site collection to another.

Parameters:
        -sourceurl <source site collection url>
        -targeturl <target site collection url>
        [-globalonly / -currentonly]
        [-showsubsites <true (default) | false>]
        [-showpages <true | false (default)>]
        [-deleteexistingglobal <true (default) | false>]
        [-deleteexistingcurrent <true (default) | false>]
        [-backuptarget <filename>]

Here’s an example of how to copy both the global (Top Nav) and current (Quick Launch) navigation from the root site collection to a site collection at the managed path “hr”:

stsadm –o gl-copynavigation –sourceurl "http://intranet/" -targeturl "http://intranet/hr" -backuptarget "c:\backup.xml"

If you’d like to just copy the global navigation and leave the quick launch (current) then you’d do the following:

stsadm –o gl-copynavigation –sourceurl "http://intranet/" -targeturl "http://intranet/hr" -backuptarget "c:\backup.xml" -globalonly

Setting -deleteexistingglobal or -deleteexistingcurrent to false would result in the source items being added to the existing navigation rather than replacing the existing navigation.

Update 11/2/2007: I’ve modified the gl-setnavigationnodes command so that it now allows you to add XML nodes to the passed in XML which will be replaced dynamically. The two nodes are <SiteCollectionUrl /> and <WebUrl />. If either (or both) of these two nodes appear in the XML they will be replaced with the appropriate server relative URL of the specified web or site collection. For example – you could have the following Node element in your XML:

1<Node Id="2030" Title="Document Center" IsVisible="True"> <Url><SiteCollectionUrl />/Docs</Url> <vti_navsequencechild>true</vti_navsequencechild> <UrlQueryString></UrlQueryString> <NodeType>Area</NodeType> <Description>Main Document Center for Site Collection</Description> <UrlFragment></UrlFragment> </Node>