Convert Modern SharePoint Page Banner Images to Base-64 using PowerShell

I was recently asked to write a PowerShell script that identified a bunch of pages and emailed them. They wanted the emails to include the Banner Image (the page/news thumbnail). No problem, I’ll just grab the handy BannerImageUrl field and stick it in some HTML, right? Nope.

Although you can certainly create an email with images using the URL, unless the user is logged in, those images will cause a bunch of authentication errors. This is especially a problem for people checking their email on their phones. Sadness!

Fortunately, you can grab those images in PowerShell and convert them to base-64 strings. That way the authentication for the images is only needed when running the script and not when the user opens the email.

The Father

Here’s a basic script that covers the concept using PnP PowerShell:

# Connect to your site
# (this example assumes an entry in Windows Credential Manager,
# but you can pass credentials however you need here)
Connect-PnPOnline https://yourtenant.sharepoint.com/sites/yoursite
$connection = Get-PnPConnection
# Setup a Web Client using credentials pulled from the connection
$client = New-Object System.Net.WebClient
$client.Credentials = New-Object Microsoft.SharePoint.Client.SharePointOnlineCredentials($connection.PSCredential.UserName, $connection.PSCredential.Password)
$client.Headers.Add("X-FORMS_BASED_AUTH_ACCEPTED", "f")
# Get a page
# (You could be doing this in a loop or using Get-PnPClientSidePage)
$page = Get-PnPListItem List "Site Pages" Id 294
# Grab the value of the image and convert it to base-64
# and slap the data information to the front
$image = "data:image/png;base64," + [convert]::ToBase64String($client.DownloadData($page.FieldValues.BannerImageUrl.Url))
# In this sample, just copying the HTML value to the clipboard to prove it works
# Normally, you'd build an HTML string and append it for your email
Set-Clipboard Value "<img src=""$image"">"
# All done!
$client.Dispose()
Disconnect-PnPOnline

In the Gist above, the HTML value is put in your clipboard. This is just to make it easy to prove it works. Run the script, paste the result in codepen in a browser where you are not authenticated to the site and witness the magic!

The key bit is the setup of the web client (lines 8-10) and the call to convert the downloaded data (line 18). You can easily wrap this logic up inside a foreach loop to process all your pages/news and build a nice html based email. Wowee!

Extending the List of Sites You can Embed From in SharePoint Using PowerShell

The Embed web part for modern pages lets you display content from secure websites right on your page. Want to show a YouTube video? Grab the embed code from youtube.com and slap it in the Embed web part. Wowee!

By default, modern pages support 30+ sites including the most common like YouTube, Vimeo, TED, and internal domains like Stream and OneDrive. But what about when you’ve got content from a site not on this list? You’ll end up with an error similar to this:

Don’t cry! Wipe those tears off that wet face! If you just need to allow the domain for a single site, the instructions are right there (here’s a quick summary):

  • Go to Site Settings
  • Click on HTML Field Security under Site Collection Administration
  • Type the domain from the error message (no https://) into the box and click Add
  • Click OK
  • Give it another try

But wait… Corporate just rolled out a video hosting platform for the enterprise and they want all sites to be able to embed content from this new site. Does the thought of repeating the above steps hundreds or even thousands of times make you weep in despair? Smack those tears off your moistened face!

Here’s a quick snippet of PowerShell which will show you how to add it to multiple sites:

$SiteUrls = @("HR","Accounting","IT")
foreach($SiteUrl in $SiteUrls) {
Write-Host ForegroundColor Cyan "Applying to $SiteUrl"
$FullSiteUrl = "https://superspecial.sharepoint.com/sites/$SiteUrl"
Connect-PnPOnline $FullSiteUrl ErrorAction Stop
$site = Get-PnPSite Includes CustomScriptSafeDomains
$ctx = Get-PnPContext
$ssDomain = [Microsoft.SharePoint.Client.ScriptSafeDomainEntityData]::new()
$ssDomain.DomainName = "special.hosted.panopto.com"
$site.CustomScriptSafeDomains.Create($ssDomain)
$ctx.ExecuteQuery()
Disconnect-PnPOnline
}

In the PowerShell above, I’m using PnP PowerShell. You can technically do this without PnP PowerShell since it’s just CSOM, but… why would you make your life harder?

Here’s what’s happening:

  • The list of sites in line 1 is just an array of the URL portion of the site after /sites/. You could easily alter this to grab all associated sites for a hub or to get all sites within a classification, etc. But I find a simple list of URLs works pretty well.
  • We connect to the site in line 9 and grab the site object in line 11
  • We get the Client Context in line 12
  • We create a new ScriptSafeDomainEntityData object and set the only part we care about, DomainName, to the URL from the error message before
  • Then in line 17 we use the Create method to add it to the list of domains (there’s no problem if the site already has that domain, it won’t be added twice)
  • We execute the query for the client context to save our changes in line 19
  • Finally we disconnect from the site in line 21 and move on to the next site

You can easily adapt the script above as part of your provisioning process to ensure that new site have the correct domains whitelisted as well. So fun!

Now you can take content from all over the web and mash it together to bring all the relevant stuff directly to your users. WOWEE!

Dog, Pug, Bitch, Pet, Animal, Obedient, Funny, Cute

Thank you SPTechCon Austin!

I had the honor of attending and speaking at SPTechCon West this week. It was great! I attended several really awesome sessions, attendees were super engaged, and I even got some sweet swag. Even better? I crushed Vlad Catrinescu at Mario Kart on the same big screen we both presented on the next day.

Getting the Most out of SharePoint Patterns and Practices (PnP)

Photo via David Warner II

On Monday I presented one of my favorite sessions. This session is like a sampler platter of the awesome stuff the PnP team and community has made available for everyone to use and learn from. There were lots of people who hadn’t heard of PnP, which means their jobs are now going to be so much easier. Whoo!

There was a lot of great feedback and participation. People were very excited about Page Transformation for Modernization and, as always, PnP PowerShell. The samples and contribution opportunities were also of great interest.

Slides: Getting the Most out of SharePoint Patterns and Practices

List Formatting in O365 and 2019

Earlier today I got to present on my other favorite topic: List Formatting. Unfortunately, I got over ambitious and tried to fill my session with tons of information AND demos. I ran out of time. Sadness.

It was a big crowd and lots of people were very excited about the amazing things you can do with List Formatting. I had lots of people asking questions afterwards and even helped setup some formats for attendees right in the room. That’s the power of List Formatting, we can apply them with no installations, deployments, or admin privileges!

Slides: O365 List Formatting

Thank you so much to all the attendees, speakers, sponsors, and organizers. SPTechCon has lots of user targeted information, but so many of the sessions went into a lot of depth that I left with lots of great tips and I’m sure everyone else did too!

Thank you North American Collaboration Summit 2018!

Over the weekend I was able to attend and speak at the North American Collaboration Summit (Sharepointalooza) in Branson, Missouri. It was an extremely well run and attended event. It’s unbelievable to me that attendees were only paying $65 for content and experience that would cost $1500-2000+ elsewhere!

I spoke on Understanding SharePoint Patterns and Practices (PnP). I love this topic because it allows me to show off amazing stuff that always has immediate “Monday” value. People always leave this session with at least one or two things they’ll start using as soon as they get back to work. SharePoint PnP is awesome but it can be difficult to know about everything that is available.

I was able to demo:

  • SharePoint PnP PowerShell
  • Remote Provisioning
  • Site Designs with Remote Provisioning
  • PnPJS
  • Column Formatter

I even demonstrated how to do the simplest (but still very much appreciated) contribution by live fixing documentation!

Thanks to everyone who attended. I got a lot of great questions and once again people were really impressed with what PnP has to offer! Awesome event, awesome sponsors, awesome speakers, and awesome attendees!

Resources

 

Using SharePoint PnP PowerShell Modules Side-by-Side (2013, 2016, & Online)

Applies to SharePoint 2013, 2016, O365

Are you using SharePoint PnP PowerShell yet? Why not!?! Developers, IT Pros, and Power Users can all benefit from the SharePoint PnP PowerShell modules. The cmdlets wrap up a bunch of complex CSOM and REST calls into 280+ awesome commands. If you’re not using SharePoint PnP PowerShell you’re doing things on hard mode.

Installing SharePoint PnP PowerShell is super easy. You just choose your target version (2013, 2016, or Online) and install. If you’re on Windows 10 you can literally type Install-Module SharePointPnPPowerShellOnline into an administrator shell and you’re done.

But what if you’re like me and have multiple versions you need to target? I find myself needing to switch between SharePoint on premises and online all the time. Unfortunately the modules are often not cross-version compatible due to the different CSOM versions supported between the products. Something as simple as Get-PnPFolder against a 2013 site using the Online module won’t work and the errors aren’t always super obvious:

Error

For a while I’ve just used the Uninstall-Module command and just switched between them that way. This has struck me as dumb for a while now so I finally reached out to The Father himself, Erwin van Hunen, and he responded right away:

Tweet

Awesome! Here’s how to do that (with screenshots!):

Install All the Modules

Although you can install all the modules, you can only have 1 active within any given session. So if you want to switch modules (once you’ve already loaded one) you’ll need to close and reopen PowerShell.

Option 1 – SharePoint On-Premises as Default

If you install the modules using the PowerShell Gallery they will be installed into the default modules path. As a result, when you use a PnP PowerShell Command the first module will be auto loaded (but the other 2 won’t because of conflicts). This appears to be alphabetical. So if you installed all 3 then the default module will be 2013. If you want to use the Online module instead, you would simply run Import-Module SharePointPnPPowerShellOnline before running any PnP PowerShell Commands.

You can’t just run the simple install command for each module. You’ll end up with some version of this error on your second module:

ClobberError

You’ll need to use the -AllowClobber parameter:

Install

You can then check what versions you have installed using this command:

Get-Module SharePointPnPPowerShell* -ListAvailable | Select-Object Name,Version | Sort-Object Version -Descending

Now you can use any of the modules without having to uninstall/install first! By default you’ll be using 2013 (or 2016 if you skipped 2013) which may match your use case perfectly! You can always use the Import-Module command to target one of the other versions.

Option 2 – SharePoint Online as Default

To have the Online module be the one that is auto loaded when you use a PnP PowerShell Command but still have the option to load one of the on premises modules, you should only install the Online module through the PowerShell Gallery:

Install-OnlineOnly

You can then check what versions you have installed using this command to ensure you only have the Online module installed here:

Get-Module SharePointPnPPowerShell* -ListAvailable | Select-Object Name,Version | Sort-Object Version -Descending

To install the other module(s) you’ll use the Releases page to download the corresponding msi installers:

Releases

Just run these as normal to get these installed. Once the module(s) are installed, open System Properties from your control panel. Under the Advanced tab, click the Environment Variables… button. Under User variables, find the PSMODULEPATH variable. If the path(s) to the PnP PowerShell modules are the only values you can just delete it. Otherwise, you can edit it to remove those paths:

EnvironmentVariables

Now when you run a PnP PowerShell Command the Online module will be auto loaded (default). If you want to use one of the on premises modules instead, you have to run Import-Module PATHTOMODULE before running any PnP PowerShell Commands.

Unfortunately, the module path can be pretty long. For instance, here’s mine:

Import-Module C:\Users\ckent\AppData\Local\Apps\SharePointPnPPowerShell2013\Modules\SharePointPnPPowerShell2013

That’s not very convenient! Fortunately, the Windows PowerShell ISE provides snippets that can make it much simpler. You can create snippets using the New-IseSnippet command:

New-IseSnippet -Title " PnP2013" -Description "Imports the SharePointPnPPowerShell2013 Module" -Text "Import-Module $env:LOCALAPPDATA\Apps\SharePointPnPPowerShell2013\Modules\SharePointPnPPowerShell2013"

I put a space before the name so it would be at the top of the snippets. You can then access the snippet from either the script editor or the ISE prompt by pressing Ctrl-J:

Snippets

 

Now you can use the Online module by default but quickly load the on premises module as needed without having to uninstall/install all the time!

Creating a PnP TemplateProviderExtension

Applies To: OfficeDev PnP, SharePoint, PowerShell

The SharePoint PnP Remote Provisioning engine is awesome. With just a couple of lines of code or some quick PowerShell you can have a deployable “template” for your SharePoint site (on-premises or O365). OfficeDev PnP offers much more, but it’s the provisioning aspect of things we’re going to talk about today.

Specifically, we’re going to talk about extending the process through the new ITemplateProviderExtension interface. In the August 2016 release the PnP team released the ability to create your own provider extensions and incorporate them directly in the retrieval and application of your PnP templates (Read the announcement here, see an example here).

These new extensions allow you to stick your custom logic directly into the generation of templates and the application of templates. This allows you to apply special tweaks, adjust output, generate additional objects/calls, etc. There are 4 entry points (see the interface below) that give you a lot of flexibility.

The Project

An extension is just a class that implements the ITemplateProviderExtension (more about this in a bit). If you are interfacing with the provisioning engine using .NET directly then you can just add the class to your project. More likely, however, you’ll want to add it as a Class Library (this is true for calling it through PowerShell as well).

In Visual Studio, add a new project of type Class Library (File > New > Project and select Class Library from the list of templates, give it a name, and click OK).

You’ll need to add the SharePoint PnP Core library NuGet package to your project. Right-Click on your project in Solution Explorer and choose Manage NuGet Packages… In the NuGet Package Manager click on Online in the left pane and type PnP into the Search Online box in the upper-right. From the results pick the SharePoint PnP Core library that matches your targeted version and click Install (I’m using SharePoint PnP Core library for SharePoint 2013 since I am targeting On-Premises SharePoint 2013):

PnP Package

This will take just a minute or so to copy everything into your project. You’ll probably be promoted to accept some licenses (just click accept). Once this is done, you can click Close.

The Interface

In your project you have a few files like Class1.cs, SharePointContext.cs and TokenHelper.cs. You can leave all of these (they won’t hurt anything). Right-click on Class1.cs and choose Rename. Enter the name of your extension. Visual Studio will also prompt you to rename the references for Class1 – Click Yes.

To implement the interface, you’ll want to slap a using statement up on top of your extension class for OfficeDevPnP.Core.Framework.Provisioning.Providers then implement the ITemplateProviderExtension like this:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using OfficeDevPnP.Core.Framework.Provisioning.Providers;

namespace MyExtension
{
    public class CommentIt : ITemplateProviderExtension
    {
    }
}

Right-Click on ITemplateProviderExtension and select Implement Interface > Implement Interface to have the class stubbed out for you:

ImplementInterface

So what the heck is all this? Let’s go through the methods and talk about what you’re going to want to use.

Entry Points

Your template provider extension can intercept the template at 4 different entry points and then do whatever it is you want to do. I find the name of the entry points a little difficult to follow, but here’s where they’re called within the life cycle of the template:

  • From SharePoint to Template (Get-SPOProvisioningTemplate)
    • Template object generated from SharePoint
      • PreProcessSaveTemplate
    • Template serialized into XML
      • PostProcessSaveTemplate
    • Template saved to file system

 

  • From Template to SharePoint (Apply-SPOProvisioningTemplate)
    • Template loaded from file system as XML
      • PreProcessGetTemplate
    • Template deserialized from XML to Template object
      • PostProcessGetTemplate
    • Template object applied to SharePoint

And here’s a reference chart:

Template Is
Action Template Object XML Stream
From SP to Template (Save) PreProcessSaveTemplate PostProcessSaveTemplate
Applying Template (Apply) PostProcessGetTemplate PreProcessGetTemplate

Supports Properties

The Supports properties indicate to the provisioning engine which entry points your extension supports (where you want to inject your logic). You’ll need to edit each of these to remove the NotImplementedException and to return true when you want to inject during that point and false when you don’t.

For my extension, I just want to tweak the XML when someone is saving the template from SharePoint so here’s what mine look like:

public bool SupportsGetTemplatePostProcessing
{
    get { return (false); }
}

public bool SupportsGetTemplatePreProcessing
{
    get { return (false); }
}

public bool SupportsSaveTemplatePostProcessing
{
    get { return (true); }
}

public bool SupportsSaveTemplatePreProcessing
{
    get { return (false); }
}

Initialize

The Initialize method is where you can pass any settings and do any setup. For my extension, I am just passing a string that I will inserting into the template XML:

private string _comment;
public void Initialize(object settings)
{
    _comment = settings as string;
}

Processing Methods

You only need to implement the methods where you indicated you were supporting them in the Supports properties. You can leave the rest with the default NotImplementedException in place.

For this example, I just want to tweak the XML when someone is saving the template from SharePoint so I returned true for the SupportsSaveTemplatePostProcessing property which means I need to implement the PostProcessSaveTemplate method. For what I’m doing, you’ll need a few more using statements:

using System.IO;
using System.Xml;
using OfficeDevPnP.Core.Framework.Provisioning.Providers.Xml;

Here’s are my methods:

public OfficeDevPnP.Core.Framework.Provisioning.Model.ProvisioningTemplate PostProcessGetTemplate(OfficeDevPnP.Core.Framework.Provisioning.Model.ProvisioningTemplate template)
{
    throw new NotImplementedException();
}

public System.IO.Stream PostProcessSaveTemplate(System.IO.Stream stream)
{
    MemoryStream result = new MemoryStream();

    //Load up the Template Stream to an XmlDocument so that we can manipulate it directly
    XmlDocument doc = new XmlDocument();
    doc.Load(stream);
    XmlNamespaceManager nspMgr = new XmlNamespaceManager(doc.NameTable);
    nspMgr.AddNamespace("pnp", XMLConstants.PROVISIONING_SCHEMA_NAMESPACE_2016_05);

    XmlNode root = doc.SelectSingleNode("//pnp:Provisioning", nspMgr);
    XmlNode commentNode = doc.CreateComment(_comment);
    root.PrependChild(commentNode);

    //Put it back into stream form for other provider extensions to have a go and to finish processing
    doc.Save(result);
    result.Position = 0;

    return (result);
}

public System.IO.Stream PreProcessGetTemplate(System.IO.Stream stream)
{
    throw new NotImplementedException();
}

public OfficeDevPnP.Core.Framework.Provisioning.Model.ProvisioningTemplate PreProcessSaveTemplate(OfficeDevPnP.Core.Framework.Provisioning.Model.ProvisioningTemplate template)
{
    throw new NotImplementedException();
}

This is a pretty silly example, but here’s what the code above is doing in the PostProcessSaveTemplate method:

  • Line 28, The method expects us to return the transformed XML steam when we’re done making our tweaks, so just getting it ready
  • Lines 31-34, We can use the native XmlDocument objects to interact with the XML Stream. We just load it into a document and account for the pnp namespace.
  • Line 36, We find the root node of the XML Template using xpath and the namespace
  • Line 37, We generate a new XML Comment using the string passed into our Initialize method
  • Line 38, We jam the comment into the root node so it shows up right at the top
  • Lines 41-44, We save the modified XmlDocument to our result stream, reset it, then pass it along its way

Using Your Extension

Great, so now we’ve got an extension! How do we use this thing? In .NET it’s as simple as initializing our extension class and passing it into the XMLTemplateProvider’s SaveAs method (see the announcement for an example).

In PowerShell, we can write a script to load the extension from our dll and provide it in the TemplateProviderExtensions argument to the Get-SPOProvisioningTemplate or Apply-SPOProvisioningTemplate cmdlets.

Here’s an example of a PowerShell script that uses my custom CommentIt extension (Be sure to heck your dll location):

[CmdletBinding()]
param
(
    [Parameter(Mandatory = $true, HelpMessage="Enter the URL of the target site, e.g. 'https://intranet.mydomain.com/sites/targetSite'")]
    [String]
    $TargetSiteUrl,

    [Parameter(Mandatory = $false, HelpMessage="Enter the filepath for the template, e.q. Folder\File.xml or Folder\File.pnp")]
    [String]
    $FilePath,

    [Parameter(Mandatory = $true, HelpMessage="Enter the comment to add!")]
    [String]
    $TemplateComment,

    [Parameter(Mandatory = $false, HelpMessage="Optional administration credentials")]
    [PSCredential]
    $Credentials
)

if(!$FilePath)
{
    $FilePath = "Extractions\site.xml"
}

if($Credentials -eq $null)
{
	$Credentials = Get-Credential -Message "Enter Admin Credentials"
}

Write-Host -ForegroundColor Yellow "Target Site URL: $targetSiteUrl"

try
{
    Connect-SPOnline $TargetSiteUrl -Credentials $Credentials -ErrorAction Stop

    [System.Reflection.Assembly]::LoadFrom("MyExtension\bin\Debug\MyExtension.dll") | Out-Null
    $commentIt = New-Object MyExtension.CommentIt
    $commentIt.Initialize($TemplateComment)

    Get-SPOProvisioningTemplate -Out $FilePath -Handlers Lists,Fields,ContentTypes,CustomActions -TemplateProviderExtensions $commentIt

    Disconnect-SPOnline

}
catch
{
    Write-Host -ForegroundColor Red "Exception occurred!"
    Write-Host -ForegroundColor Red "Exception Type: $($_.Exception.GetType().FullName)"
    Write-Host -ForegroundColor Red "Exception Message: $($_.Exception.Message)"
}

Please note, that you’ll need the PnP PowerShell Cmdlets installed for this to work. Instructions can be found here. I am using the 2013 On-Premise version but this script will work with whatever version you’re using.

Here’s what’s happening in this script:

  • Lines 1-29, Just setting up the parameters for the script. Nothing too special here
  • Line 31, Always nice to remind the user of important details
  • Line 35, Connect to SharePoint with a single line – wowee!
  • Line 37, Load up your dll from the file system (You can provide a full or relative path here). The pipe to Out-Null just keeps us from printing dll information to the console which would be strange to an end user
  • Line 38, Get your extension class as an object using the namespace from your dll
  • Line 39, Call the Initialize method of the extension. In this case we are passing in the comment received as a parameter to the script
  • Line 41, This is a standard call to Get-SPOProvisioningTemplate with the exception that we are specifying our custom extension in the TemplateProviderExtensions parameter
  • Line 43, Close up that connection

If we take a look at the XML file generated by our template (With a TemplateComment parameter of Look at this sweet comment!), we can see:

Comment.PNG

Aw yeah, boyo!

Debugging Your Extension

Generally, you’re going to be doing something more complicated than that and you’ll probably want to debug the thing. If you are calling your extension in .NET within Visual Studio then things are pretty much as you’d expect – Set your breakpoints and run the thing. PowerShell is a little less obvious.

To debug your extension in the script above, you just need to see your breakpoints within the extension (say on the Initialize method). Then use the Debug > Attach to Process command within Visual Studio. Scroll through the processes until you find where your PowerShell script is running. I generally use the Windows PowerShell ISE to edit my scripts and that shows up as powershell_ise.exe. Choose it then click Attach:

AttachToProcess.PNG

Now when you run your script, your breakpoints should be hit. Fun Note, you’ll need to close and open the powershell window in order to release the dll when you want to make adjustments and build it.

Now you’re ready to take advantage of this incredibly powerful extension point! You can find the full code for this sample extension here. Have fun!

Intercepting SharePoint Email and Testing Outgoing E-Mail Settings

Applies To: SharePoint

I’m going to show you how to temporarily reroute SharePoint’s outgoing email to your desktop where you can either ignore it or forward it as necessary. There are a variety of legitimate reasons to do this. Generally I’ve used this when working in a small development or testing environment where email isn’t setup and it would be a huge hassle. However, I’ve also done this to test changes that involve email blasts (like false alarms on mysite deletions or screwy workflow debugging). As with anything, use some common sense about how and when to use this.

To run a dummy SMTP server on your desktop you’ll want to use smtp4dev by RNWood. This is a great little program that sits in the system tray and intercepts messages sent to it. You can then view, save or even forward them (from your account) and more. I’ve used it for years for all sorts of projects and I highly recommend it.

Configuring Outgoing Email

Generally your outgoing email settings in SharePoint were configured as part of your initial farm setup. To adjust these settings you’ll need to head to Central Administration. In SharePoint 2010 you’ll want to click on System Settings then choose Configure outgoing e-mail settings under E-Mail and Text Messages (SMS):

CentralAdmin

There’s only one thing we need to adjust here and that’s the Outbound SMTP server. If this is just a temporary change, be sure to write down the current value somewhere so you can set it back. Just put the name of your desktop here (Depending on your environment you may need to use your IP Address or whatever DNS entry points to where you are running smtp4dev). If you haven’t already configured your From address and Reply-to address go ahead and do that. you can just make them up (they don’t have to correspond to an actual mailbox, they just need to be email addresses). Your settings should look something like this:

MailSettings

Once you hit okay you will start receiving emails in smtp4dev (So be sure it’s running). You can also make these changes using Stsadm and can apply them to individual web applications instead of the whole farm like shown above. See this article for more details: Configure outgoing e-mail.

Sending a Test Email from SharePoint

So how do you know it’s working? Unless this is a large production farm with lots of alerts and other things flying off all the time you may not see anything at all. You can of course setup alerts on some list and then trigger events that would send the alert, but that can be a big hassle. Fortunately, you can have SharePoint send you an email through PowerShell.

Just copy and paste the script below and save as TestEmail.ps1:

$sd = New-Object System.Collections.Specialized.StringDictionary
$sd.Add("to","you@somedomain.com")
$sd.Add("from","spadmin@somedomain.com")
$sd.Add("subject","Test Email")
$w = Get-SPWeb http://somedomain.com
$body = "This is a test email sent from SharePoint, Wowee!!"

try {
	[Microsoft.SharePoint.Utilities.SPUtility]::SendEmail($w,$sd,$body)
}
finally {
	$w.Dispose()
}

This is a pretty straightforward script. It builds a StringDictionary with some obvious entries and then calls the SendEmail function of the SPUtility class to have SharePoint send the email. Just replace the dictionary entries with appropriate values and be sure to switch the web address (line 5) to one of yours. To run it, open the SharePoint Management Shell (You’ll want to run-as a farm administrator) and navigate to the location of the script. You can run it by typing: .\TestEmail.ps1

TestEmail

If you switch back over to where you are running smtp4dev you should see a new entry:

smtp4devTestEmail

You can double click the message to open it in your mail program:

MessageTestEmail

You can also click Inspect to see all the details:

InspectTestEmail

 

That’s it! Just be sure to switch the settings back if this was a temporary test. Of course, as long as you use your real email address in the to entry above then you can run the TestEmail.ps1 script again to ensure things are sending properly after you switch things back.

Top Link Bar Navigation From XML

Applies To: SharePoint

In my last post, Top Link Bar Navigation To XML, I provided you with a script to serialize a site collection’s Global Navigation Nodes to XML. In the post before that, Multi-Level Top Link Bar Navigation (Sub Menus), I showed you how to enable additional sub menus using the native control in SharePoint by editing a simple attribute in the Master Page. However, it quickly became clear that the native navigation editor (Site Actions > Site Settings > Navigation) won’t allow you to edit anything greater than 2 levels despite the control’s support for it. In this final post I’ll show you how to edit the exported XML to add multiple levels and then to import it.

The Script

Copy and paste the code below into notepad and save it as NavigationFromXml.ps1

$xmlFile = "d:\scripts\navigation\ExportedNavigation.xml"
$sourcewebApp = "http://somesite.com"
$destweb = "http://somesite.com"
$keepAudiences = $true #set to false to have it totally ignore audiences (good for testing)

Function CreateNode($xNode,$destCollection){
    Write-Host $xNode.Title

    #make URLs relative to destination
    $hnUrl = $xNode.Url
    #Write-Host "O URL: $hnUrl"
    $hnUrl = SwapUrl $hnUrl $sourceWebApp $destWeb
    #Write-Host "N URL: $hnUrl"

    $hnType = $xNode.NodeType
    if($hnType -eq $null){
        $hnType = "None"
    }

    $hNode = [Microsoft.SharePoint.Publishing.Navigation.SPNavigationSiteMapNode]::CreateSPNavigationNode($xNode.Title,$hnUrl,[Microsoft.SharePoint.Publishing.NodeTypes]$hnType, $destCollection)
    $hNode.Properties.Description = $xNode.Description
    if($keepAudiences){
        $hNode.Properties.Audience = $xNode.Audience
    }
    $hNode.Properties.Target = $xNode.Target
    $hNode.Update()

    if($xNode.Children.Count -gt 0) {
        foreach($child in $xNode.Children) {
            CreateNode $child $hNode.Children
        }
    } elseif($xNode.Children.IsNode -eq "yes") {
        #single child
        CreateNode $xNode.Children $hNode.Children
    }
}

Function SwapUrl([string]$currentUrl,[string]$sourceRoot,[string]$destRoot) {
	if ($currentUrl -ne "/") {
		if ($currentUrl.StartsWith("/")) {
            #Relative URL
			$currentUrl = $sourceRoot + $currentUrl
		} elseif ($currentUrl.StartsWith($destRoot)) {
            #May be a problem for non root sites
			$currentUrl = $currentUrl.Replace($destRoot,"")
		}
	} else {
		$currentUrl = [System.String]::Empty
	}
	$currentUrl
}

$dw = Get-SPWeb $destweb
$pdw = [Microsoft.SharePoint.Publishing.PublishingWeb]::GetPublishingWeb($dw)

$gnn = Import-Clixml $xmlFile

$nodeCount = $pdw.Navigation.GlobalNavigationNodes.Count
Write-Host "  Removing Current Nodes ($nodeCount)..." -ForegroundColor DarkYellow
for ($i=$nodeCount-1; $i -ge 0; $i--) {
    $pdw.Navigation.GlobalNavigationNodes[$i].Delete()
}

if($gnn.IsNode -Eq "yes"){
    #not an array (just 1 top level nav item)
    #Write-Host "1 Only!"
    [void](CreateNode $gnn $pdw.Navigation.GlobalNavigationNodes)
} else {
    #array of nodes, so add each one
    foreach($n in $gnn){
        [void](CreateNode $n $pdw.Navigation.GlobalNavigationNodes)
    }
}

#cleanup
$dw.dispose()

What it Does and How to Use It

There are 4 parameters at the top of the script for you to adjust:

  • $xmlFile: The path to use for the XML input
  • $sourcewebApp: The URL of the web application (Needed to ensure relative links are imported correctly)
  • $destweb: The URL of the site collection you are importing the navigation nodes to
  • $keepAudiences: When $true audience values are used, when $false they are ignored (helpful for testing)

Once you set those and save, open the SharePoint Management Shell (You’ll want to run-as a farm administrator) and navigate to the location of the script. You can run it by typing: .\NavigationToXml.ps1

RunNavigationFromXMLScript

The script will delete all global navigation nodes from the $destweb. It will then use the Import-Clixml command to hydrate a series of custom objects that it will use to build the new navigation nodes. It will build the nodes recursively allowing any number of levels of child nodes (You will have to adjust your Master Page as outlined in my post, Multi-Level Top Link Bar Navigation (Sub Menus), to see any more than the default 2 levels).

How it Works

The main code begins at line 53 where we retrieve the $destweb and then hydrate the $gnn object from the $xmlFile. One of the custom properties used in our NavigationToXml.ps1 script we output was IsNode. In PowerShell an array of one object does not serialize to an array. Rather, it serializes directly to the single object. Using IsNode allows us to know if the object we are working with is an actual node or an array of nodes so that we can avoid exceptions when accessing other properties.

For every node we hydrated we call the function CreateNode (lines 6-36) which creates a node using the custom properties in the passed collection. URLs are made relative to the web application using the function SwapUrl (lines 38-51). This will process every node in the collection along with all of their children.

Editing the XML

So why go through this at all? If you just want to copy global navigation from one site collection to another then just use the simpler NavigationPropagation.ps1 script provided in this article: SharePoint Top Link Bar Synchronization.

However, doing it this way allows you a chance to tweak the XML using notepad (I recommend notepad++). This is the easiest way to add Multiple Levels. For now I’ll explain the basics of the XML document and how to edit it. We’ll go over more of the whys in the Putting it All Together section below.

Structure

To get your base structure it’s best to use the native navigation editor (Site Actions > Site SettingsNavigation) and setup as many of your nodes as you can. Then you can use the NavigationToXml.ps1 script provided in the previous article, Top Link Bar Navigation To XML, as your base document. Trust me, trying to write it all from scratch is dumb. Here’s a quick summary of the results of running that script against navigation that looks like this:

IMG_0347

Skipping the automatic link for the site (Navigation Propagation) here is the current structure of those nodes:

  • Some Sites (Heading)
    • theChrisKent (AuthoredLinkPlain)
    • Microsoft (AuthoredLinkPlain)
  • Google (AuthoredLinkPlain)

Here’s what the exported XML looks like for that same set of nodes:

<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04">
  <Obj RefId="0">
    <TN RefId="0">
      <T>System.Object</T>
    </TN>
    <ToString>System.Object</ToString>
    <MS>
      <S N="IsNode">yes</S>
      <S N="Title">Some Sites</S>
      <S N="Url">/personal/ckent/navtest</S>
      <S N="NodeType">Heading</S>
      <S N="Description"></S>
      <S N="Audience"></S>
      <Nil N="Target" />
      <Obj N="Children" RefId="1">
        <TN RefId="1">
          <T>System.Object[]</T>
          <T>System.Array</T>
          <T>System.Object</T>
        </TN>
        <LST>
          <Obj RefId="2">
            <TNRef RefId="0" />
            <ToString>System.Object</ToString>
            <MS>
              <S N="IsNode">yes</S>
              <S N="Title">theChrisKent</S>
              <S N="Url">http://thechriskent.com</S>
              <S N="NodeType">AuthoredLinkPlain</S>
              <S N="Description"></S>
              <S N="Audience"></S>
              <Nil N="Target" />
              <Obj N="Children" RefId="3">
                <TNRef RefId="1" />
                <LST />
              </Obj>
            </MS>
          </Obj>
          <Obj RefId="4">
            <TNRef RefId="0" />
            <ToString>System.Object</ToString>
            <MS>
              <S N="IsNode">yes</S>
              <S N="Title">Microsoft</S>
              <S N="Url">http://microsoft.com</S>
              <S N="NodeType">AuthoredLinkPlain</S>
              <S N="Description"></S>
              <S N="Audience"></S>
              <Nil N="Target" />
              <Ref N="Children" RefId="3" />
            </MS>
          </Obj>
        </LST>
      </Obj>
    </MS>
  </Obj>
  <Obj RefId="5">
    <TNRef RefId="0" />
    <ToString>System.Object</ToString>
    <MS>
      <S N="IsNode">yes</S>
      <S N="Title">Google</S>
      <S N="Url">http://google.com</S>
      <S N="NodeType">AuthoredLinkPlain</S>
      <S N="Description"></S>
      <S N="Audience"></S>
      <Nil N="Target" />
      <Ref N="Children" RefId="3" />
    </MS>
  </Obj>
</Objs>

Each node can be found in an Obj element where you can easily find the list of custom properties in the MS section (IsNode, Title, Url, NodeType, Description, Audience, Target and Children). Each of these are simple strings. The only one that can be a little tricky is Audience which we’ll cover in depth in the Adding Nodes section below.

Each Obj element has a unique (to this document) value for it’s RefId. This is just an integer. The only thing to really note here is that each one needs to be unique. They will be the case in any export since this is part of the Export-Clixml command, but you’ll need to pay close attention to this when adding additional nodes. These are also used in Ref elements which you’ll see in various spots. If an object is the exact same as an object previously defined in the document it won’t be defined. Rather it will just get a Ref element instead of an Obj element. The Ref element will have a RefId that is equal to the previously defined Obj‘s RefId.

This mostly comes up with blank children. A good example is the first node with no children above is the node for theChrisKent (lines 22-38). You can see that the Children Obj is defined in lines 33-36. Whereas, the very next node without children (Microsoft lines 39-52) doesn’t have an Obj for Children but rather a Ref (line 50) with a RefId of 3 which you’ll recognize as the RefId specified in line 33. This can seem very confusing at first but it will get easier using the examples below.

Adding Nodes

Adding a single node with no children is pretty easy. Just cut and paste a similar node (Obj) and switch up the RefId to something unique for the document. For instance if I wanted to create another link under Some Sites right after the Microsoft one, I could just copy the Microsoft node (lines 39-52) and paste it directly below its closing tag (</Obj>). It would look something like this:

<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04">
  <Obj RefId="0">
    <TN RefId="0">
      <T>System.Object</T>
    </TN>
    <ToString>System.Object</ToString>
    <MS>
      <S N="IsNode">yes</S>
      <S N="Title">Some Sites</S>
      <S N="Url">/personal/ckent/navtest</S>
      <S N="NodeType">Heading</S>
      <S N="Description"></S>
      <S N="Audience"></S>
      <Nil N="Target" />
      <Obj N="Children" RefId="1">
        <TN RefId="1">
          <T>System.Object[]</T>
          <T>System.Array</T>
          <T>System.Object</T>
        </TN>
        <LST>
          <Obj RefId="2">
            <TNRef RefId="0" />
            <ToString>System.Object</ToString>
            <MS>
              <S N="IsNode">yes</S>
              <S N="Title">theChrisKent</S>
              <S N="Url">http://thechriskent.com</S>
              <S N="NodeType">AuthoredLinkPlain</S>
              <S N="Description"></S>
              <S N="Audience"></S>
              <Nil N="Target" />
              <Obj N="Children" RefId="3">
                <TNRef RefId="1" />
                <LST />
              </Obj>
            </MS>
          </Obj>
          <Obj RefId="4">
            <TNRef RefId="0" />
            <ToString>System.Object</ToString>
            <MS>
              <S N="IsNode">yes</S>
              <S N="Title">Microsoft</S>
              <S N="Url">http://microsoft.com</S>
              <S N="NodeType">AuthoredLinkPlain</S>
              <S N="Description"></S>
              <S N="Audience"></S>
              <Nil N="Target" />
              <Ref N="Children" RefId="3" />
            </MS>
          </Obj>
          <Obj RefId="10">
            <TNRef RefId="0" />
            <ToString>System.Object</ToString>
            <MS>
              <S N="IsNode">yes</S>
              <S N="Title">Apple</S>
              <S N="Url">http://apple.com</S>
              <S N="NodeType">AuthoredLinkPlain</S>
              <S N="Description"></S>
              <S N="Audience"></S>
              <Nil N="Target" />
              <Ref N="Children" RefId="3" />
            </MS>
          </Obj>
        </LST>
      </Obj>
    </MS>
  </Obj>
  <Obj RefId="5">
    <TNRef RefId="0" />
    <ToString>System.Object</ToString>
    <MS>
      <S N="IsNode">yes</S>
      <S N="Title">Google</S>
      <S N="Url">http://google.com</S>
      <S N="NodeType">AuthoredLinkPlain</S>
      <S N="Description"></S>
      <S N="Audience"></S>
      <Nil N="Target" />
      <Ref N="Children" RefId="3" />
    </MS>
  </Obj>
</Objs>

Pretty straightforward overall. Notice that in line 53 I’ve set the RefId to 10. This is because the only requirement is for it to be unique – It does not have to be in sequence. If you run the NavigationFromXml.ps1 script on the above the site now looks like this:

TopLinkBarWithAppleLink

What about an additional level? For this example we’ll be adding a new sub menu under Some Sites called Pizza with 2 links. Our structure should look like this:

  • Some Sites (Heading)
    • theChrisKent (AuthoredLinkPlain)
    • Microsoft (AuthoredLinkPlain)
    • Apple (AuthoredLinkPlain)
    • Pizza (Heading)
      • Pizza Hut (AuthoredLinkPlain)
      • Little Caesars (AuthoredLinkPlain)
  • Google (AuthoredLinkPlain)

Here’s what our modified XML looks like now:

<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04">
  <Obj RefId="0">
    <TN RefId="0">
      <T>System.Object</T>
    </TN>
    <ToString>System.Object</ToString>
    <MS>
      <S N="IsNode">yes</S>
      <S N="Title">Some Sites</S>
      <S N="Url">/personal/ckent/navtest</S>
      <S N="NodeType">Heading</S>
      <S N="Description"></S>
      <S N="Audience"></S>
      <Nil N="Target" />
      <Obj N="Children" RefId="1">
        <TN RefId="1">
          <T>System.Object[]</T>
          <T>System.Array</T>
          <T>System.Object</T>
        </TN>
        <LST>
          <Obj RefId="2">
            <TNRef RefId="0" />
            <ToString>System.Object</ToString>
            <MS>
              <S N="IsNode">yes</S>
              <S N="Title">theChrisKent</S>
              <S N="Url">http://thechriskent.com</S>
              <S N="NodeType">AuthoredLinkPlain</S>
              <S N="Description"></S>
              <S N="Audience"></S>
              <Nil N="Target" />
              <Obj N="Children" RefId="3">
                <TNRef RefId="1" />
                <LST />
              </Obj>
            </MS>
          </Obj>
          <Obj RefId="4">
            <TNRef RefId="0" />
            <ToString>System.Object</ToString>
            <MS>
              <S N="IsNode">yes</S>
              <S N="Title">Microsoft</S>
              <S N="Url">http://microsoft.com</S>
              <S N="NodeType">AuthoredLinkPlain</S>
              <S N="Description"></S>
              <S N="Audience"></S>
              <Nil N="Target" />
              <Ref N="Children" RefId="3" />
            </MS>
          </Obj>
          <Obj RefId="10">
            <TNRef RefId="0" />
            <ToString>System.Object</ToString>
            <MS>
              <S N="IsNode">yes</S>
              <S N="Title">Apple</S>
              <S N="Url">http://apple.com</S>
              <S N="NodeType">AuthoredLinkPlain</S>
              <S N="Description"></S>
              <S N="Audience"></S>
              <Nil N="Target" />
              <Ref N="Children" RefId="3" />
            </MS>
          </Obj>
          <Obj RefId="11">
            <TNRef RefId="0" />
            <ToString>System.Object</ToString>
            <MS>
              <S N="IsNode">yes</S>
              <S N="Title">Pizza</S>
              <S N="Url">http://apple.com</S>
              <S N="NodeType">Heading</S>
              <S N="Description"></S>
              <S N="Audience"></S>
              <Nil N="Target" />
              <Obj N="Children" RefId="12">
                <TNRef RefId="1" />
                <LST>
                  <Obj RefId="13">
                    <TNRef RefId="0" />
                    <ToString>System.Object</ToString>
                    <MS>
                      <S N="IsNode">yes</S>
                      <S N="Title">Pizza Hut</S>
                      <S N="Url">http://www.pizzahut.com</S>
                      <S N="NodeType">AuthoredLinkPlain</S>
                      <S N="Description"></S>
                      <S N="Audience"></S>
                      <Nil N="Target" />
                      <Ref N="Children" RefId="3" />
                    </MS>
                  </Obj>
                  <Obj RefId="14">
                    <TNRef RefId="0" />
                    <ToString>System.Object</ToString>
                    <MS>
                      <S N="IsNode">yes</S>
                      <S N="Title">Little Caesars</S>
                      <S N="Url">http://www.littlecaesars.com</S>
                      <S N="NodeType">AuthoredLinkPlain</S>
                      <S N="Description"></S>
                      <S N="Audience"></S>
                      <Nil N="Target" />
                      <Ref N="Children" RefId="3" />
                    </MS>
                  </Obj>
                </LST>
              </Obj>
            </MS>
          </Obj>
        </LST>
      </Obj>
    </MS>
  </Obj>
  <Obj RefId="5">
    <TNRef RefId="0" />
    <ToString>System.Object</ToString>
    <MS>
      <S N="IsNode">yes</S>
      <S N="Title">Google</S>
      <S N="Url">http://google.com</S>
      <S N="NodeType">AuthoredLinkPlain</S>
      <S N="Description"></S>
      <S N="Audience"></S>
      <Nil N="Target" />
      <Ref N="Children" RefId="3" />
    </MS>
  </Obj>
</Objs>

We’ve now added an object with NodeType set to Heading since this is required in order to support having children. We’ve also created a Children Obj that is not a Ref. It has a TNRef and a LST. Inside the LST we just add more of those AuthoredLinkPlain nodes like we did before. You can repeat this same trick for infinite levels down. Running NavigationFromXml.ps1 may result in something like this:

TopLinkBarWithPizzaNoSubMenu

What happened? We can see Pizza but it’s not a sub menu like we expected! These nodes are all there but by default SharePoint doesn’t show them. You’ll need to adjust your Master Page using the techniques in this article: Multi-Level Top Link Bar Navigation (Sub Menus). Once you’ve done that you’ll see something like this:

TopLinkBarWithPizzaAndSubMenu

What about Audiences? This one was a little trickier to get right. The easiest thing to do is apply an audience to a node using the built in navigation editor and then export it using NavigationToXml.ps1 to see what the value should be. But what about when you’ve already done that and you want to manually edit it? An actual audience (Not a SharePoint Group but a compiled audience) is just specified as the GUID followed by 4 semi-colons. If you wish to do more than one then just put a comma between the GUIDs and then add on 4 semi-colons on the end. Here’s what that looks like:

Single Audience:

<S N="Audience">45175fed-9cc8-45ad-9cd6-dda031bf7577;;;;</S>

Multiple Audiences:

<S N="Audience">0a6e46d0-8f45-487d-a249-141fe4a8c429,8b722c7b-a1b4-4f4f-8035-2bac6294b713;;;;</S>

Putting it All Together

This has been a 4 part series on how to improve Top Link Bar navigation:

I’ve provided you with 3 PowerShell scripts (NavigationPropagation.ps1, NavigationToXml.ps1 and NavigationFromXml.ps1) and told you how to make necessary changes to your Master Page. So how do we use all of these?

Because we want multiple sub menus we made the change to our Master Page(s) to set the MaximumDynamicDisplayLevels to 2 (2 is all we wanted, but feel free to go higher as needed). Then we setup our top level links and ran the NavigationToXml.ps1 script just to get our starter structure (We haven’t really needed it since). We made all the adjustments to add our sub menus and nodes then ran the NavigationFromXml.ps1 script to get all that populated.

For changes to our navigation we just update the XML file, run NavigationFromXml.ps1 and then run NavigationPropagation.ps1 to synchronize our changes across our site collections. It works really well. Hopefully you’ll find this system or some parts of it to be helpful too!

Top Link Bar Navigation To XML

Applies To: SharePoint

Continuing in my series on SharePoint’s Top Link Bar (sometimes called the Tab Bar) I want to show you how to serialize a site collection’s Global Navigation Nodes to XML using PowerShell. This isn’t quite the same as just using the Export-Clixml command since we are interested in very specific properties and their format.

In my previous post, Multi-Level Top Link Bar Navigation (Sub Menus), I showed you how to enable additional sub menus using the native control in SharePoint by editing a simple attribute in the Master Page. However, it quickly became clear that the native navigation editor (Site Actions > Site Settings > Navigation) won’t allow you to edit anything greater than 2 levels despite the control’s support for it. In this post I’ll show you how to output a site’s navigation to XML. In my next post I’ll show how to edit it and then import it to create multiple levels.

The Script

Copy and paste the code below into notepad and save it as NavigationToXML.ps1

$xmlFile = "d:\scripts\navigation\ExportedNavigation.xml"
$sourceweb = "http://somesite.com"
$keepAudiences = $true

Function ProcessNode($nodeCollection) {
    $parentCollection = @()
    foreach($node in $nodeCollection) {
       if ($node.Properties.NodeType -ne [Microsoft.SharePoint.Publishing.NodeTypes]::Area -and $node.Properties.NodeType -ne [Microsoft.SharePoint.Publishing.NodeTypes]::Page) {
	   $nHash = New-Object System.Object
           $nHash | Add-Member -type NoteProperty -name IsNode -value "yes"
           $nHash | Add-Member -type NoteProperty -name Title -value $node.Title
           $nHash | Add-Member -type NoteProperty -name Url -value $node.Url
           $nHash | Add-Member -type NoteProperty -name NodeType -value $node.Properties.NodeType
           $nHash | Add-Member -type NoteProperty -name Description -value $node.Properties.Description
           if($keepAudiences){
                $nHash | Add-Member -type NoteProperty -name Audience -value $node.Properties.Audience
           } else {
                $nHash | Add-Member -type NoteProperty -name Audience -value ""
           }
           $nHash | Add-Member -type NoteProperty -name Target -value $node.Target
           if($node.Children.Count -gt 0){
                $nHashChildren = ProcessNode($node.Children)
                $nHash | Add-Member -type NoteProperty -name Children -value $nHashChildren
           } else {
                $nHash | Add-Member -type NoteProperty -name Children -value @()
           }
           $parentCollection += $nHash
       }
    }
    $parentCollection
}

$sw = Get-SPWeb $sourceweb
$psw = [Microsoft.SharePoint.Publishing.PublishingWeb]::GetPublishingWeb($sw)

ProcessNode($psw.Navigation.GlobalNavigationNodes) | Export-Clixml $xmlFile

#cleanup
$sw.dispose()

What it Does and How to Use It

There are 3 parameters at the top of the script you will need to change:

  • $xmlFile: The path to use for the XML output
  • $sourceweb: The URL of the site whose navigation you are serializing
  • $keepAudiences: When $true audience values are serialized, when $false they are left blank

Once you set those and save, open the SharePoint Management Shell (You’ll want to run-as a farm administrator) and navigate to the location of the script. You can run it by typing: .\NavigationToXml.ps1

RunNavigationToXMLScript

The script will create an array of custom objects that correspond to the $sourceweb‘s navigation nodes and then use standard PowerShell serialization to create a simplified XML document at the location specified by $xmlFile. For instance, given the navigation shown here:

IMG_0347

The XML output will look similar to this:

<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04">
  <Obj RefId="0">
    <TN RefId="0">
      <T>System.Object</T>
    </TN>
    <ToString>System.Object</ToString>
    <MS>
      <S N="IsNode">yes</S>
      <S N="Title">Some Sites</S>
      <S N="Url">/personal/ckent/navtest</S>
      <S N="NodeType">Heading</S>
      <S N="Description"></S>
      <S N="Audience"></S>
      <Nil N="Target" />
      <Obj N="Children" RefId="1">
        <TN RefId="1">
          <T>System.Object[]</T>
          <T>System.Array</T>
          <T>System.Object</T>
        </TN>
        <LST>
          <Obj RefId="2">
            <TNRef RefId="0" />
            <ToString>System.Object</ToString>
            <MS>
              <S N="IsNode">yes</S>
              <S N="Title">theChrisKent</S>
              <S N="Url">http://thechriskent.com</S>
              <S N="NodeType">AuthoredLinkPlain</S>
              <S N="Description"></S>
              <S N="Audience"></S>
              <Nil N="Target" />
              <Obj N="Children" RefId="3">
                <TNRef RefId="1" />
                <LST />
              </Obj>
            </MS>
          </Obj>
          <Obj RefId="4">
            <TNRef RefId="0" />
            <ToString>System.Object</ToString>
            <MS>
              <S N="IsNode">yes</S>
              <S N="Title">Microsoft</S>
              <S N="Url">http://microsoft.com</S>
              <S N="NodeType">AuthoredLinkPlain</S>
              <S N="Description"></S>
              <S N="Audience"></S>
              <Nil N="Target" />
              <Ref N="Children" RefId="3" />
            </MS>
          </Obj>
        </LST>
      </Obj>
    </MS>
  </Obj>
  <Obj RefId="5">
    <TNRef RefId="0" />
    <ToString>System.Object</ToString>
    <MS>
      <S N="IsNode">yes</S>
      <S N="Title">Google</S>
      <S N="Url">http://google.com</S>
      <S N="NodeType">AuthoredLinkPlain</S>
      <S N="Description"></S>
      <S N="Audience"></S>
      <Nil N="Target" />
      <Ref N="Children" RefId="3" />
    </MS>
  </Obj>
</Objs>

For those familiar with the Export-Clixml PowerShell command this shouldn’t look too crazy. For the rest of us, I will go into detail about this in my next post. Regardless of how complicated that may look to you, it is much simpler than if we had just called Export-Clixml on the Global Navigation Nodes object for the site.

How it Works

The main code begins at line 33 where the $sourceweb is retrieved and then the ProcessNode function (Lines 5-31) is called on the GlobalNavigationNodes collection. This generates an array of custom objects that are then serialized to the $xmlFile using Export-Clixml.

The ProcessNode function creates a custom object for every node and captures the Title, Url, NodeType, Description, Target, and when $keepAudiences is $true the audience information. Every custom object is also given a Children property (lines 21-26) and this is set to an array recursively for all child nodes that exist. This has been written to capture any number of levels of children. (This script does, however, skip all Area and Page node types since these are automatic and shouldn’t be edited manually)

 

So what do we do with this XML document? In my next post I’ll show you how to read, edit and ultimately import these nodes back into the site collection. This can be helpful to copy nodes from one site colleciton to another (although my post on SharePoint Top Link Bar Synchronization provided a much cleaner approach for this). More importantly this will provide you an easy way to have multiple sub menus in the SharePoint Top Link Bar.

SharePoint Top Link Bar Synchronization

Applies To: SharePoint

The Top Link Bar (sometimes called the Tab bar) provides global navigation for a site collection. It’s a great spot to link to subsites, other site collections and more. It’s also relatively easy to use and customize. On a standard team site you can go to Site Actions > Site Settings and then choose the Navigation link under Look and Feel to customize. This all works really well. However, it can be difficult to maintain consistent navigation between multiple site collections. Add in the complication of relative URLs, audiences and attempting to keep those sites in sync with any more than 2 or 3 site collections – suddenly any navigational changes become a full project!

IMG_0347

Fortunately, using the below PowerShell script you can easily setup navigation on a single site collection using the out of the box tools and then propagate those changes to as many additional site collections as needed:

The Script

You can copy the script below and paste it into notepad and save it as NavigationPropagation.ps1

$sourceweb = "http://somesite.com"
$destwebs = "http://somesite.com/sites/humanresources", "http://somesite.com/panda", "http://someothersite.com/pickles"

$keepAudiences = $true #set to false to have it totally ignore audiences (good for testing)

Function CreateNode($node,$dWeb,$destCollection){
    if ($node.Properties.NodeType -ne [Microsoft.SharePoint.Publishing.NodeTypes]::Area -and $node.Properties.NodeType -ne [Microsoft.SharePoint.Publishing.NodeTypes]::Page) {

        Write-Host "    Node: " + $node.Title

        #make URLs relative to destination
    	$hnUrl = $node.Url
    	#Write-Host "O URL: $hnUrl"
        $IsHeading = $false
        if($node.Properties.NodeType -eq [Microsoft.SharePoint.Publishing.NodeTypes]::Heading){
            $IsHeading = $true
        }
    	$hnUrl = SwapUrl $hnUrl $sourceWeb $dWeb $IsHeading
    	#Write-Host "N URL: $hnUrl"

        $hnType = $node.Properties.NodeType
        if($hnType -eq $null){
            $hnType = "None"
        }

        $hNode = [Microsoft.SharePoint.Publishing.Navigation.SPNavigationSiteMapNode]::CreateSPNavigationNode($node.Title,$hnUrl,[Microsoft.SharePoint.Publishing.NodeTypes]$hnType, $destCollection)
    	$hNode.Properties.Description = $node.Properties.Description
        if($keepAudiences){
    	   $hNode.Properties.Audience = $node.Properties.Audience
        }
    	$hNode.Properties.Target = $node.Properties.Target
        $hNode.Update()

        if($node.Children.Count -gt 0) {
    	   foreach($child in $node.Children) {
                CreateNode $child $dWeb $hNode.Children
    	   }
        }
    }
}

Function SwapUrl([string]$currentUrl,[string]$sourceRoot,[string]$destRoot,[boolean]$isHeading) {
	if ($currentUrl -ne "/") {
		if ($currentUrl.StartsWith("/")) {
			$currentUrl = $sourceRoot + $currentUrl
		} elseif ($currentUrl.StartsWith($destRoot)) {
			$currentUrl = $currentUrl.Replace($destRoot, "")
		}
	} else {
		$currentUrl = [System.String]::Empty
        if(!$isHeading){
            Write-Host "        This is a blank non-heading! Using $sourceRoot" -ForegroundColor Yellow
            $currentUrl = $sourceRoot
        }
	}
	$currentUrl
}

$sw = Get-SPWeb $sourceweb
$psw = [Microsoft.SharePoint.Publishing.PublishingWeb]::GetPublishingWeb($sw)

foreach ($destweb in $destwebs) {

	Write-Host "***************************************" -ForegroundColor Cyan
	Write-Host "Propogatin' That There $destweb" -ForegroundColor Cyan
	Write-Host "***************************************" -ForegroundColor Cyan

	$dw = Get-SPWeb $destweb
	$pdw = [Microsoft.SharePoint.Publishing.PublishingWeb]::GetPublishingWeb($dw)

	$nodeCount = $pdw.Navigation.GlobalNavigationNodes.Count
	Write-Host "  Removing Current Nodes ($nodeCount)..." -ForegroundColor DarkYellow
	for ($i=$nodeCount-1; $i -ge 0; $i--) {
		$pdw.Navigation.GlobalNavigationNodes[$i].Delete()
	}

	Write-Host "  Propagating to $destweb..." -ForegroundColor DarkYellow
	foreach($n in $psw.Navigation.GlobalNavigationNodes){
		[void](CreateNode $n $destweb $pdw.Navigation.GlobalNavigationNodes)
	}

	$dw.dispose()
}

$sw.dispose()

What it Does and How to Use it

Since this is a script that you will typically run with the same parameters over and over I just made them variables at the top of the script. There are just 3 of them:

  • $sourceweb: This is the url of the site collection to copy the navigation from
  • $destwebs: This is a comma separated (array) list of urls to copy the navigation to
  • $keepAudiences: When $true audiences are propagated along with the links, when $false then these are totally ignored. Setting this to $false can be really helpful for testing just to make sure everything is being copied correctly before you add in the complication of audience filtering

Once you set those and save, open the SharePoint Management Shell (You’ll want to run-as a farm administrator) and navigate to the location of the script you can simply run it by typing: .\NavigationPropagation.ps1

IMG_0349

The script will cycle through each of the $destwebs and delete all the Global Navigation Nodes from the site. It will then copy all the nodes (excluding Area and Page types since these are automatic depending on your settings) from the $sourceweb. Each URL is made relative or adjusted to be fully qualified as needed. This is done recursively allowing any number of levels of child nodes (more on this in an upcoming post).

Typical use case for us is to save the script with all the parameters set and whenever we make a change on the $sourceweb we simply run the script and everything gets synchronized. If you’re making frequent updates then you could always use Task Scheduler to run the script automatically.

How it Works

There are 2 functions in the script: CreateNode (lines 4-40) and SwapUrl (lines 42-57). CreateNode copies a passed node into the specified collection mapping the necessary properties and calling SwapUrl which ensures relative Urls are fully qualified and Urls that should be relative are made relative. The main thing to note is that CreateNode is recursive and will copy all child nodes as well.

The main execution of the script starts at line 59 and is pretty straightforward. It retrieves the source web and then loops through the destination webs. For each destination web it deletes all the existing navigation nodes and then calls CreateNode passing each of the source web’s nodes and the destinations node collection.

 

Keeping your navigation consistent across the farm is essential to a good user experience but can be difficult to manage using out of the box tools. Fortunately with the above script this can be relatively simple.