Vue normale

Il y a de nouveaux articles disponibles, cliquez pour rafraîchir la page.
À partir d’avant-hierMarc D Anderson's Blog

Using ShareGate PowerShell to Download SharePoint Content

You might be surprised by the title of this post. Isn’t our goal to get everyone’s content *into* SharePoint? Usually that’s the case, for sure.

But many times, in the course of a migration from an older (usually on premises) version of SharePoint, we identify whole sites or branches of subsites that simply don’t have any real purpose anymore. In some cases, we migrate the content into SharePoint Online, maybe into a site called Archive or similar. But other times we just want to save the content somewhere *in case* someone needs it. Which approach you take may hinge on your organizational culture, what storage mechanisms you have available, and the relative importance of the content – among many other things.

I’ve worked on a PowerShell script for this recently and used it for several different clients. With it, I can point at the top level of a branch of sites – maybe something like /sites/HR or /departments/Finance, and the script will download the content from that site, and recursively from all its subsites, etc. into a network location of my choosing, say C:\ or Z:\Archive.

I’ve run this script with both SharePoint 2010 and SharePoint 2016 as the source version, so I expect it will work for you in most cases.

The script is pretty straightforward because I use ShareGate’s PowerShell module to do all the heavy lifting: yet another reason why ShareGate rocks! If you have ShareGate installed on your machine, you have their PowerShell module as well, even if you didn’t realize it.

Note the ShareGate module requires you to be running PowerShell 5.x, NOT the more current PowerShell 7.x.

The script comes in two pieces:

  • downloadSite.ps1 – The script I actually run, after setting three variables appropriately.
  • downloadSiteFunctions.psm1 – A module with functions I call above. If you want to do recursion, you’ll need functions of some sort, and using a separate module gives some flexibility for reuse.

The parameter settings I show in the downloadSite.ps1 below were what I wanted for a particular scenario. When you read through downloadSiteFunctions.psm1 below, you’ll see what the impact of those settings is.

Import-Module -Name ShareGate # Requires ShareGate to be installed on the machine
Import-Module "./PowerShell/downloadSiteFunctions.psm1" -Force

# Setup
$sourceSiteName = "Name for the downloaded folder"
$sourceSiteUrl = "https://FarmOrTenantName/siteName/"

# The downloads will end up in a folder here named $sourceSiteName
$destTop = "Z:\" # Be sure to include a trailing backslash

# Any list or library in this array will be excluded from the downloads
$exclusionLists = @(
    "Content and Structure Reports",
    "Master Page Gallery",
    "Reusable Content",
    "Style Library",
    "Web Part Gallery",
    "Workflow Tasks",
    "Microfeed",
    "Site Pages",
    "Site Assets"
)

# Process root web
#   Delete existing folder - we assume we want to start from scratch
Remove-Item `
    -Path "$($destTop)$($sourceSiteName)" `
    -Recurse `
    -Force

#   Create new top-level folder
$top = New-Item `
    -Path "$($destTop)$($sourceSiteName)" `
    -ItemType Directory -Force

#   Export lists
Export-SympLists `
    -ParentFolder "$($top.FullName)" `
    -WebUrl $sourceSiteUrl `
    -Versions $false `
    -ExclusionLists $exclusionLists `
    -KeepEmpty $false `
    -KeepLists $true

# Process subwebs
Get-SympSubwebs `
    -ParentFolder "$($top.FullName)" `
    -WebUrl $sourceSiteUrl `
    -Versions $false `
    -ExclusionLists $exclusionLists `
    -KeepEmpty $false `
    -KeepLists $true

The downloadSiteFunctions.psm1 file might seem complicated, but it’s just two functions:

  • Export-SympLists – Exports all list/library contents from a web using ShareGate PowerShell functions. The ShareGate function Export-List is a workhorse. It exports ALL lists and libraries to folders. Even better, it creates an Excel file containing the inventory with all the metadata as well as the Document (libraries) or Attachments (lists).
  • Get-SympSubwebs – Gets the subwebs of any web and exports their list contents using ShareGate PowerShell functions. By calling itself as a last step, the function enables recursion. Essentially, it “walks” the subsite (web) topology from the current site (web) on down.
<#
.DESCRIPTION
   Exports list contents from a web using ShareGate PowerShell functions
.EXAMPLE
   Export-SympLists -ParentFolder $parentFolder -WebUrl $webUrl -Versions $versions
#>
function Export-SympLists {
   [CmdletBinding()]
   [Alias()]
   [OutputType([int])]
   Param
   (
      # Parent Folder
      [string]
      $ParentFolder,
 
      # Web URL
      [string]
      $WebUrl,
 
      # Versions - Should we download versions (or only the latest version) $true = all versions
      [boolean]
      $Versions = $false,
 
      # ExclusionLists - Array of list names to *skip* in the download
      [array]
      $ExclusionLists = @(),

      # KeepEmpty - Will create a folder for every library even if it is empty.
      # Setting this to $false will delete the empty folders.
      [boolean]
      $KeepEmpty = $false,

      # KeepLists - Will create a folder for every list even if it is empty.
      # Setting this to $true will keep all lists, regardless of their number of items.
      [boolean]
      $KeepLists = $false
 
   )
  
   Begin {
  
      Write-Host "Processing web $($WebUrl)"
 
   }
   Process {
  
      # ShareGate's Connect-Site
      $srcSite = Connect-Site $WebUrl

      # ShareGate's Get-List
      $srcLists = Get-List -Site $srcSite

      # Filter out the exclusionList items, if any
      foreach ($exclusionList in $ExclusionLists) {
         $newLists = $srcLists | Where-Object { $_.Title -ne $exclusionList } 
         $srcLists = $newLists
      }

      # If there's something to download, do it.
      if ($srcLists.length -gt 0) {
         # If we want to keep versions 
         if ($Versions) {
            $result = Export-List -List $srcLists -DestinationFolder "$($ParentFolder)"
         }
         else {
            # Else we don't want to keep versions 
            $result = Export-List -List $srcLists -DestinationFolder "$($ParentFolder)" -NoVersionHistory 
         }
      }

      # If #KeepLists, then keep all lists
      if ($KeepLists) {
         $srcLists = $srcLists | Where-Object { $_.RootFolder -inotmatch "/Lists/" }
      }

      # If !$KeepEmpty, delete the folders which have no content
      if (!$KeepEmpty) {
         foreach ($list in $srcLists) {
            $listPath = "$($ParentFolder)\$($list.Title)"
            $documents = Get-Item -Path "$($listPath)\Documents\*" -ErrorAction Ignore
            if ($documents.length -eq 0) {
               Remove-Item -Path $listPath -Force -Confirm:$false -Recurse
            }
         }
      }

   }
   End {
   }
}
 
 
<#
 .DESCRIPTION
    Gets the subwebs of any web and exports their list contents using ShareGate PowerShell functions
 .EXAMPLE
    Get-SympSubwebs -ParentFolder $parentFolder -WebUrl $webUrl -Versions $versions 
 #>
function Get-SympSubwebs {
   [CmdletBinding()]
   [Alias()]
   [OutputType([int])]
   Param
   (
      # Parent Folder
      [string]
      $ParentFolder,
 
      # Web URL
      [string]
      $WebUrl,
 
      # Versions - Should we download versions (or only the latest version) $true = all versions
      [boolean]
      $Versions,
 
      # ExclusionLists - Array of list names to skip in the download
      [array]
      $ExclusionLists,

      # KeepEmpty - Will create a folder for every library even if it is empty.
      # Setting this to $false will delete the empty folders.
      [boolean]
      $KeepEmpty = $false,

      # KeepLists - Will create a folder for every list even if it is empty.
      # Setting this to $true will keep all lists, regardless of their number of items.
      [boolean]
      $KeepLists = $false

   )
  
   Begin {
  
      Write-Host "Getting subwebs of $($WebUrl)"
 
   }
   Process {
  
      # ShareGate's Connect-Site
      $siteConnection = Connect-Site $WebUrl

      # ShareGate's Get-Subsite
      $webs = Get-Subsite -Site $siteConnection
 
      # Process each web
      foreach ($web in $webs) {
 
         # Remove illegal characters in the web title
         $cleanTitle = $web.Title.Replace("#", "").Replace(":", " - ").Replace("/", "-").Replace("""", "'")

         # Variable for the web's folder - note the leading "_"
         $rootFolder = "$($ParentFolder)\_$($cleanTitle)" 
 
         # Create the web's folder
         $newFolder = New-Item -Path $rootFolder -ItemType Directory -Force

         # Download the lists/libraries with the provided parameters
         Export-SympLists -ParentFolder $rootFolder -WebUrl "$($web.Address)" -Versions $Versions -ExclusionLists $ExclusionLists -KeepEmpty $KeepEmpty -KeepLists $KeepLists

         # Get the web's subwebs - this is the recursion
         Get-SympSubwebs -ParentFolder $rootFolder -WebUrl "$($web.Address)" -Versions $Versions -ExclusionLists $ExclusionLists -KeepEmpty $KeepEmpty -KeepLists $KeepLists

      }
 
   }
   End {
   }
}
 

Here’s an example. Let’s say I set the variables in downloadSite.ps1 like so:

# Setup
$sourceSiteName = "Brazil"
$sourceSiteUrl = "https://myTenant/Brazil/"

# The downloads will end up in a folder here named $sourceSiteName
$destTop = "Z:\"

I end up with something like the following set of folders. Brazil is the top-level site, and it has 3 subsites: Facilities, Human Resources, and Recruitment. Notice that each subsite’s folder’s name starts with an underscore so we can easily understand the tree at a glance.

Within each subsite’s folder, all the lists and libraries within it (if requested) are represented as folders.

Each library’s folder contains the Excel file with the metadata, and a subfolder containing the documents themselves.

Each list’s folder contains the Excel file with the metadata, and a subfolder containing the attachments, if any.

This script gives me a good scaffolding and some options I can use to get content out of SharePoint in an organized and broad way. I’ve already used it with two clients, and I firmly expect I’ll use it again.

Find All the Stream (Classic) Web Parts During Migration to Stream in SharePoint

Another day, another opportunity to spackle the walls of SharePoint where there’s a hole. Wouldn’t it be great if you could go somewhere in the SharePoint Admin Center to see all the places you’ve used a particular Web Part in pages? Well, you can’t, so PowerShell. PnP.PowerShell, in fact.

You may be considering migrating your videos from Stream Classic to Stream in SharePoint. After all, it’s got to happen sooner or later, and the migration tool is now available for everyone. At Sympraxis, we’ve started to help our clients with these migrations, usually in the context of other work.

One thing you’re likely to want to know is where you have used the Stream (Classic) Web Part in your pages. Depending on how you do the migration, it is likely going to be a good idea to visit many of those pages to either switch to the Document Library Web Part or at least validate the Stream (Classic) Web Parts are working.

Just as I did when I upgraded from the PnP Modern Search Web Parts v3 to v4 (See: Upgrading the PnP Modern Search Web Parts from v3 to v4: Where are they?), I turned to PowerShell. I grabbed that same script and buffed it up a bit to find the Stream (Classic) Web Parts this time.

I took a bit of a different approach with this iteration, though. Rather than using search to find the pages with the Web Parts (I found it was missing some), I switched to using Get-PnPPageComponent | PnP PowerShell. This allows me to get the Web Parts (aka Page Components) in a page directly.

The script below is what I used. I’ve included some comments to indicate where you might choose to do things a bit differently, depending on your environment and your goals. I’m just outputting the info to the console, as the tenant where I’m working isn’t that dense. You may choose to output to a CSV file or something else.

Want to know more about migrating to Stream in SharePoint? Watch the recording of our AskSympraxis from November 30, 2022: Migrating from Stream Classic.

# findStreamClassicWebParts.ps1 - Inventory Stream Classic Web Parts to ensure they still work after migration

# Connect to your tenant here. This should be the only change you need to make to use this script.
$tenant = "sympmarc"
$adminConnection = Connect-PnPOnline -Url "https://$($tenant)-admin.sharepoint.com" -Interactive  -ReturnConnection

# Get all the sites to check
# Checking all the Communication Sites and Team Sites
# $sites = Get-PnPTenantSite | Where-Object { $_.Template -eq "SITEPAGEPUBLISHING#0" -or $_.Template -eq "GROUP#0" }

# Checking sites associated with the Intranet (Home Site)
$sites = Get-PnPHubSiteChild -Connection $adminConnection -Identity "https://$($tenant).sharepoint.com" | Sort-Object

# You may choose to exclude some subsets of sites
$filteredSites = $sites | Where-Object { $_ -eq "https://$($tenant).sharepoint.com/sites/Exec-BoardRelations" }

foreach ($site in $filteredSites) {
    Write-Host -BackgroundColor White -ForegroundColor Black "Looking in $($site)"

    # Get the pages
    $siteConnection = Connect-PnPOnline -Url $site -Interactive -ReturnConnection
    $pages = Get-PnPListItem -Connection $siteConnection -List "Site Pages" | Where-Object { $_.FieldValues.File_x0020_Type -eq "aspx" }

    foreach($page in $pages) {
        #Write-Host -BackgroundColor White -ForegroundColor Black "Checking $($page.FieldValues.FileLeafRef)"
        $streamPage = Get-PnPPageComponent -Connection $siteConnection -Page $page.FieldValues.FileLeafRef | Where-Object { $_.Title -eq "Stream" } | Select-Object Title, WebPartId
        if($streamPage) {
            Write-Host -BackgroundColor Green -ForegroundColor Black ">>> Found Stream Classic Web Parts in this page: $($page.FieldValues.Title) - $($page.FieldValues.FileDirRef)"
        }

    }

}

Using PnP.PowerShell in Visual Studio Code

PnP.PowerShell is one of my favorite tools of the trade. I’ve had to set up multiple machines for myself or others for this lately, and I always find myself looking for the fastest path to glory. Usually, it takes about 9 articles and 15 blind alleys, so I figured I’d capture what seems to work for me. Hopefully I can keep this up to date if things change.

Install Visual Studio Code

Visual Studio Code aka VS Code aka VSCode aka Code (which I’ll use in the rest of this post) is the “modern”, free code editor from Microsoft. I’ve used dozens of code editors over the years and Code is one of the best. Plus, everyone else is using it!

Obviously, you need to have VS Code installed to start. You can download it from Download Visual Studio Visual Studio Code – Mac, Linux, Windows.

Set Execution Policy

This one gets me every time. You’ll want your Execution Policies set like this:

This allows you to install PowerShell modules with less friction. It’s possible your organization won’t let you make this change. You can see your current settings by typing

Get-ExecutionPolicy -List

in a terminal window. To open things up, run this cmdlet:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine

I’m sure there are reasons to set this in different ways based on your organization’s view of security. I’m not going to get into that here: heed your governance rules.

Install PowerShell 7

If you’re running a Windows machine, you’ve most likely got PowerShell 5 (PS5) installed by default. PowerShell 7 (PS7) has more capabilities and is required for PnP.PowerShell to run successfully. Some cmdlets may run just fine with PS5, but don’t be fooled: you want PS7.)

Installing PowerShell on Windows – PowerShell | Microsoft Docs

Install the PowerShell Extension

One of the great things about Code is the rich ecosystem of extensions. The PowerShell extension from Microsoft makes Code smart about PowerShell. You want it.

PowerShell – Visual Studio Marketplace

Switch Code to PS7

I find the instructions for this confusing. PS5 is also called Windows PowerShell (x64) and PS7 is also called PowerShell (x64). In other words, the 5 and 7 don’t show un in the instructions in Using Visual Studio Code for PowerShell Development – PowerShell | Microsoft Docs.

This part is clear:

Use the following steps to choose the version:

  1. Open the Command Palette on Windows or Linux with Ctrl+Shift+P. On macOS, use Cmd+Shift+P.
  2. Search for Session.
  3. Click on PowerShell: Show Session Menu.
  4. Choose the version of PowerShell you want to use from the list.

You’ll want to choose PowerShell (x64), if it isn’t already selected.

Pro tip: When you’ve got a PowerShell file (.ps1, .psm1, etc.) open, you can also get to the PowerShell Session Menu by clicking on the squiggly brackets next to PowerShell in the bottom toolbar. Plus, the version is there!

Install PnP.PowerShell

Finally, the piece de resistance: PnP.PowerShell. This is the module that lets us do so much with Microsoft 365. If you’re using the SPO module instead, I say switch.

You need to run Code as an administrator if you want to install modules. To do this, I usually just type Code in the search box in Windows 11, right click the result, and choose Run as administrator.

From here, follow the instruction on the Installing PnP PowerShell | PnP PowerShell page.

Happy PowerShelling!


This article is for those of you on a Windows machine. I don’t have a Mac, nor do I want a Mac. I also don’t run Linux. Or a Sinclair Z-80 (though I loved the one I had way back when, it wouldn’t run PowerShell).

I expect I’ve missed a few little bits here. Feel free to tell me so in the comments, and I’ll make updates. Also, let me know if this is helpful!

References

Replace that Classic SharePoint Root Site

Working in a client tenant today, I noticed a new suggestion in the SharePoint Admin Center I hadn’t seen before. It’s certainly possible it’s been around for a while, but it was new to me.

The tenant where I’m working has been around since at least 2015. That means it was created with a classic SharePoint site as the root site. Back then there was no such thing as a modern SharePoint site, and Microsoft hasn’t forced us to replace what we got when we created the tenant. In the message above, they are suggesting site modernization. Interesting, I thought.

We do this modernization all the time as we help people build new Intranets or try to get more from their investment in the Microsoft 365 platform. It’s not at all unusual for an organization to have been using Microsoft 365 for years but maybe only using it for Exchange. Or maybe using one Document Library in the root site to store ALL the organization’s documents. Believe me, we’ve seen all sorts of things which would surprise you. It’s one of the reasons we’re so passionate about the Microsoft 365 Maturity Model.

The great thing about this recommendation is it actually steps you through the process. I figured it might be useful to know how it works for those of you who aren’t into the platform up to your elbows like we are.

When you click the View recommendation button, you get a nice set of suggested steps to follow to set up and configure a new modern site for the root.

Here are those steps and links. Generally, organizations will want a modern Communication Site in the root of their tenant. This site tends to be the launchpad for the organization’s Intranet. At the very least, it can be a launchpad into Team Sites where people do their work.

  1. Plan the site content. This step includes understanding the goals of stakeholders and the needs of users. Learn more
  2. Build the site. Create a communication site, customize the design, and add your content. Learn more
  3. Prepare to launch. Set site permissions and test the site. Learn more

The process of building out the new Communication Site need not be complex, but usually you’re planning an entirely new Intranet or set of access paths for folks to get their work done. Thus, while building the new site isn’t complex (you can create a new Communication Site in about 7 seconds!), deciding how it should look, what navigation it should have, what content it should house, etc. can take some real planning. Let’s gloss over all that, shall we?

Once you have the new Communication Site – we tend to build it in a location like /sites/NEWIntranet – you’ll come back and click the Replace root site button. On the following screen, you simply provide the URL to the Communication Site you’ve just built. Note the caveats: the new site can be a Communication Site or a Team Site, but it can’t be a Hub Site or connected to a Microsoft 365 Group. This is because swapping a site into the root means fixing up a lot of links and other stuff under the covers.

Also note the existing root site will be moved to an archive location. You won’t lose the site or its contents – but it won’t be in the same spot anymore.

At this point, I’ve stopped my exploration because clicking that Save button is going to initiate what we call a “site swap”. Once it’s done, your new site will be in the root location, the old site will be in the archive location, and everything should work just great. You’ve put a whole communication plan about this is place before you click the button, right?


If you want more control over this process, you can do what some of us have been doing for several years now: you can use the PnP.PowerShell Invoke-PnPSiteSwap cmdlet. You still build the new site in a new location, but when it comes time to do the site swap, you simply call the cmdlet, something like this:

$tenant = "mytenant"
$adminSiteUrl = "https://$($tenant)-admin.sharepoint.com"

Connect-PnPOnline $adminSiteUrl -Interactive

Invoke-PnPSiteSwap `
    -SourceUrl "https://$($tenant).sharepoint.com/sites/Intranet" `
    -TargetUrl "https://$($tenant).sharepoint.com/" `
    -ArchiveUrl "https://$($tenant).sharepoint.com/sites/Archive"

This may appeal to you because you can specify the archive destination, but the net effect is exactly the same. Note that you can only use this cmdlet to swap a site into the root; you can’t arbitrarily swap two other sites. (You can however, rename them.)


It’s great to see suggestions like this coming into the admin UIs. It’ll help a lot of organizations with a support staff of one – and there are more of those than most people think!

SharePoint Site Lock and Remove from Search Results

At Sympraxis, we often work with clients who have been using SharePoint for a long time. In many of these cases, they have been using SharePoint basically as a cloud-based file server. At some point in the past, they have done a lift and shift from “on premises” servers (wherever they may live) into SharePoint on premises or SharePoint Online. They are getting little value for their use of the platform, and they want to move up the Microsoft 365 Maturity Model.

One of the things we often need to do is migrate content from its current location into a more refined and purpose-built site topology and information architecture.

When we do that migration, we don’t want to delete the old site(s). Instead, we want to lock them from accidental updates and remove them from the search index. Since we’re migrating the content into new locations, we don’t want to old content to show up in search results.

The PowerShell below is useful in that it allows us to take care of those two steps easily. It’s meant as an example, but it is working code. You might more realistically wrap this in a foreach to apply to multiple sites at the same time.

$tenantName = "your tenant name here"
$spRoot = "https://$($tenantName).sharepoint.com"
$siteCollectionUrlFragment = "foo" # Part of the URL after /sites/, e.g., HRTeam, Marketing, etc.

# Work on this Site Collection
$siteCollection = "$(spRoot)/sites/$($siteCollectionUrlFragment)"

# Connect to Admin Center
$adminSiteUrl = "https://$($tenantName)-admin.sharepoint.com/"
$adminConnection = Connect-PnPOnline -Url $AdminSiteUrl -Interactive
 
# Connect to Site Collection
$siteCollectionConnection = Connect-PnPOnline -Url $siteCollection -Interactive

# Needed to set NoCrawl
Set-PnPSite -Identity $siteCollection -DenyAndAddCustomizePages $false
 
# Exclude Site Collection from Search Index
$Web = Get-PnPWeb -Connection $siteCollectionConnection
$Web.NoCrawl = $true
$Web.Update()
Invoke-PnPQuery

# Lock the site
Set-PnPSite -Identity $siteCollection -LockState ReadOnly

Changing a SharePoint Site URL When Connected to Microsoft Teams

I shot myself in the foot today and I figured I’d share how I bandaged it back up. In actual fact, the healing was automagical.

We had a Microsoft Team with its usual backing SharePoint site, and we wanted to reclaim the URL from that SharePoint site. This isn’t an unusual occurrence when there isn’t much governance around Team or site creation. People create Teams with whatever names – and thus URLs – makes sense to them. Retrofitting some governance can take some renaming.

Changing a SharePoint site’s URL isn’t that hard these days. I changed the URL in the SharePoint Admin Center easily. See Change a site address – SharePoint in Microsoft 365 | Microsoft Docs for the steps.

Since we wanted to reuse the URL, the next step was to delete the redirect site which is left behind for the old URL PnP.Powershell. See my post Cleaning Up Redirect Sites in SharePoint Online for how and why you might want to do this.

After the deletion of the redirect site, we realized the team had been accessing the SharePoint site exclusively in Microsoft Teams, so we went to check that the files were still available in Teams. Uh-oh. No, they weren’t.

In Microsoft Teams, the Files tab in each channel was now broken, which is understandable – in retrospect. When we clicked into a Files tab, we got one of the standard “cute” error messages for a Document Library.

Panic ensued, at least on my end. I don’t like it when I break stuff.

I talked to my very smart colleagues at Sympraxis and we couldn’t come up with a reasonable fix for this. It was a good discussion, though, and showed the breadth of knowledge we have among us. I spent some time in Binglage, too, of course.

After a while, I was looking at the Files tabs again, and I noticed the in the General channel’s Files tab was working fine. Hmm. I tried another channel (this Team has 13 channels), and it was broken. I tried another Files tab – also broken. I went back to the first non-General Files tab, quite by accident, and it was working fine again.

Turns out, Teams was able to heal each Files tab by itself. By navigating to each of the Files tabs, navigating to another tab, and navigating back to the Files tab, Teams “fixed up” its connection to the corresponding folder in the SharePoint site’s Documents library. If I caught it right, on a few of the clicks into a Files tab, I saw the following message screen, showing that Teams was working on it.

I’m extremely relieved that Teams was able to self-heal in this situation. While I was incautious in my actions, Teams was smart enough to fix itself for me. This is a sign of the good stuff Microsoft is doing these days, realizing end users make mistakes, and so do people like me, even with a lot of experience.

Get All the Public or Private Team Sites in SharePoint with PnP.PowerShell

When you create a Team Site in SharePoint, you have an option to set the Team Site as:

  • Public – anyone in the organization can access this site, or
  • Private – only members can access this site

Many people don’t know what the effect this setting has – I need to remind myself from time to time.

Private means the site is not discoverable; only people who are assigned specific permissions for the site can find it – even if they happen to guess the URL. Private Team Sites make sense when even knowing the name of the Team Site might be problematic. “Surprise Birthday Party” or “Firing People” come to mind.

Public means the site is discoverable; anyone in the organization can find the site using search or even just guessing the URL. More important, when you create a site as Public, the Everyone except external users pseudogroup is added to the Members for the site. This means every licensed user can add and edit content – unless you change the permission settings.

If any of the above is a surprise to you, well I’m not surprised by that. I don’t think the wording on the Create site dialog is all that clear, and unless I’m missing something, I can’t find a Microsoft article which explains it in the amount of detail I give above.

If you’d like to know what the setting is for any particular site, here’s how you’ll know. As of this writing (it’s moved around a bit in the past and can vary based on your header settings for the site), you can tell if a site you’re visiting is Public or Private by looking in the upper right on the home page:

That’s well and good if you have a site or two to check. But what if you’d like to know how all your sites are set up? Time to turn to trusty PnP.PowerShell. I needed to do this today and it was really hard to figure out where the setting was available. Turns out, it’s not a setting on the site itself, but on the underlying Microsoft 365 Group.

Once I knew that, easy-peasy.

This single line of PnP.PowerShell will retrieve all the Microsoft 365 Groups where the Visibility property is set to Private. If you want the Public sites, well I’ll leave that to you, dear reader.

Get-PnPMicrosoft365Group -IncludeSiteUrl | Where-Object { $_.Visibility -eq "Private"}

Of course, you’ll need the appropriate permissions and you’ll need to have connected to the SharePoint Admin site with Connect-PnPOnline first.


If you’d rather not use PowerShell (and Todd will be offended of you don’t!), you can also see this setting in the Exchange admin center (microsoft.com) in the Groups listing. That listing can be exported to Excel if you’d like to do more slicing and dicing.

References

Cleaning Up Content Types Orphaned from the Content Type Hub

One of my clients who doesn’t have a blog came up with a good fix the other day. Since he didn’t have a place to put it, he offered it to me to post.

Have you ever deleted a Content Type from the Content Type Hub which you had previously published – without unpublishing it first? If so, you probably have that Content Type sitting in all your Site Collections (now modern sites) and you can’t delete it because it came from the Content Type Hub.

Well, there’s a fix for that. The article I list in the Resources section below gets you there for on premises versions of SharePoint, but it says for SPO you should open a ticket with Microsoft support. (Comments about Microsoft support withheld.) No support ticket required.

If it’s a wholesale problem (lots of orphaned Content Types), then the script in the article will be helpful. After all, you need to figure out which Content Types are actually orphaned first. But if you know a specific single Content Type is an issue, you can simply recreate it using the original Name and Id.

Here’s an example, using PnP.PowerShell, of course! The important bit is line 5, where you recreate the Content Type which is orphaned. Obviously, you would use your own:

  • Tenant Name
  • Name
  • Id
  • Group
$cth = "https://sympmarc.sharepoint.com/sites/contentTypeHub"

Connect-PnPOnline -Url $cth -Interactive

Add-PnPContentType -Name "IT Template Document" -ContentTypeId "0x0101001611568CC163BF408A2B9341656A02290701" -Group "MyGroup" 

Once you’ve recreated the Content Type with the same name Name and Id, you can publish it and then unpublish it to get rid of the orphans.

The modern Content Type Gallery is a bit smarter. If you delete a Content Type from there, you’ll get this message, which basically says the Content Type – if published – will be unpublished before it is deleted. No more orphans! This is a good example why you should stop using the classic UI for the Content Type Hub.

Resources

How to Unpublish Orphaned Content Types in SharePoint, Script Provided (collabware.com) – Osama Khan

Cleaning Up Redirect Sites in SharePoint Online

As our tenants evolve, we end up renaming sites and/or changing their URLs. When we change the URL of a site in the SharePoint Admin Center, the URL changes, just like we asked. SharePoint also leaves the old site in place, but converts it into a Redirect Site. So if we had a site at /sites/Procurement and we decide to change it to /sites/Purchasing (maybe due to a reorganization), people who have bookmarked the Procurement site will be redirected to the Purchasing site.

The Redirect Sites we end up with aren’t shown in the Active Sites listing. The only way we can figure out they are even there is to use PowerShell.

In most cases, we want that redirection to occur. But what about the cases where we mistakenly set up a site at the wrong URL? For example, maybe we create a site at /sites/HS by mistake, so we change it to /sites/HR. Later, we may have a new department called Highly Socialized. (Roll with it.) That /sites/HS URL is already “used up” by the Redirect Site. If we try to create a site with the /sites/HS URL, we’ll end up with /sites/HS2. SharePoint helpfully avoids a naming collision by adding the 2, and then a 3, then 4 – see if you can spot the pattern!

This becomes especially important if you’re creating sites with their URLs matching some sort of coding scheme. For example, for a property management company we work with, each Property has its own site with a URL using the Property Code. The property with a Property Code of 211 lives at /sites/211. By using that rigid connection to a property of the Property, we can guarantee unique URLs and keep them simple. Something like /sites/TheHilton-PhoenixEast just aren’t that fun to type!

If we mistakenly create the site for Property Code 211 at /sites/212 and then rename the URL to the correct /sites/211, we can get ourselves into trouble. What happens when we need to create a site for property 212? SharePoint will create the URL /sites/2122, which just isn’t right!

Therefore, we often want to clean out some of the Redirect Sites. Here’s the simple PowerShell to do this. Redirect Sites use the Site Template RedirectSite#0.

# Import modules
Import-Module PnP.PowerShell

$adminSiteUrl = "https://tenantName-admin.sharepoint.com"

Connect-PnPOnline -Url $adminSiteUrl -Interactive

$redirectSites = Get-PnPTenantSite -Template "RedirectSite#0"

########################################################################
# STOP HERE - Validate the redirect sites you actually want to remove. #
########################################################################

foreach ($site in $redirectSites) {
    Remove-PnPTenantSite -Url $site.Url -Force    
}

Note the big warning at line 10. Most likely, you won’t want to delete ALL the Redirect Sites. Once you’ve found them all, figure out what filter you might want to apply to $redirectSites before you run the foreach loop. Maybe you’ll do something like:

$redirectSites = Get-PnPTenantSite -Template "RedirectSite#0" | Where-Object { $_.Url -eq "https://tenantName.sharepoint.com/sites/212"

or

$redirectSites = Get-PnPTenantSite -Template "RedirectSite#0" | Where-Object { $_.Url -gt "https://tenantName.sharepoint.com/sites/211"

Good site hygiene is important, if only to protect your sanity. And use the URL renaming function thoughtfully. Don’t think of it as an easy way to clean up your mistakes, or your tenant may end up littered with Redirect Sites you don’t even know you have.

Resources

Manage site redirects – SharePoint in Microsoft 365 | Microsoft Docs

Clean Up Unwanted Site Columns from Content Types and Lists/Libraries

Another day, another chunk of PowerShell.

Sometimes when we iteratively build out our information architecture, we’re over-zealous. It seems like we need a set of Site Columns to maintain metadata on lists or libraries, but in the end, we decide we want to trim away a few of the Site Columns we’ve created. Or, maybe you’ve migrated a bunch of metadata into SharePoint with a set of documents and it turns out that metadata is no longer valid or useful.

If the Site Columns exist in only one or two libraries, it’s not a big deal to do this manually.

Today, we decided to remove several Site Columns across a dozen or so Content Types, which are applied to a dozen or so libraries. Sure, I could have cleaned things up manually, but instead, I wrote some PowerShell to do it because I can see myself using this again.

When we want to clean up use of a Site Column – and we’re setting up our information architecture well – there are three main steps:

  • Remove the Site Column from all Content Types which have it.
  • Remove the orphaned Site Column from all lists/libraries which have it. When we remove a Site Column from Content Types which are enabled on lists or libraries, the orphaned Site Column remains. This makes sense, because you probably have some data in that column. To truly remove it, you need to remove the column in each list as well.
  • Remove the Site Column itself. This removes it from the site entirely.

Here’s the PowerShell I came up with.

# Import modules
Import-Module PnP.PowerShell

# Base variables
$siteURL = "https://tenant.sharepoint.com/sites/sitename"
$siteColumn = "EffectiveDate"
$reportOnly = $false # If $true, just report. If $false, take action.

# Connect to the tenant
$siteConnection = Connect-PnPOnline -Url $siteUrl -Interactive -ReturnConnection

# Remove the Site Column from all Content Types which have it
Write-Host -BackgroundColor Blue "Checking Content Types"

# Get all the Content Types. Here, I have all my custom Content Types in a Group called _ClientName.
$cts = Get-PnPContentType -Connection $siteConnection | Where-Object { $_.Group -eq "_ClientName" }

foreach ($ct in $cts) {

    Write-Host "Checking Content Type $($ct.Name)"

    $fields = Get-PnPProperty -ClientObject $ct -property "Fields" | Where-Object { $_.InternalName -eq $siteColumn }
    $field = $fields | Where-Object { $_.InternalName -eq $siteColumn }

    if ($field) {
        Write-Host -ForegroundColor Green "Found column $($siteColumn) in $($ct.Name)"
        if (!$reportOnly) {
            Write-Host -ForegroundColor Yellow "Removing column $($siteColumn) in $($ct.Name)"
            Remove-PnPFieldFromContentType -Field $field -ContentType $ct -Connection $siteConnection
        }
    }

}

# Remove the orphaned Site Column from all lists/libraries which have it
Write-Host -BackgroundColor Blue "Checking Lists"

# Get all lists/libraries in the site, but exclude System or Hidden lists
$lists = Get-PnPList -Connection $siteConnection | Where-Object { $_.Hidden -ne $true -and $_.IsSystemList -ne $true }

foreach ($list in $lists) {

    Write-Host "Checking list $($list.Title)"

    $field = Get-PnPField -List $list | Where-Object { $_.InternalName -eq $siteColumn }

    if ($field) {
        Write-Host -ForegroundColor Green "Found column $($siteColumn) in $($list.Title)"

        if (!$reportOnly) {
            Write-Host -ForegroundColor Yellow "Removing column $($siteColumn) in $($list.Title)"
            Remove-PnPField -Identity $field -List $list -Connection $siteConnection -Force
        }

    }

}

# Remove the Site Column itself
if (!$reportOnly) {
    Remove-PnPField -Identity $siteColumn
}

When I run this with $reportOnly = $true, I get output which looks something like this. The script just shows me where the Site Column is used.

Assuming the report above looks right, I can set $reportOnly = $false and take the actions required to remove the Site Column wherever it’s used.

Grant Permissions to All Communication Sites Associated with a Hub Site

This isn’t rocket science, but it’s something I do often enough that I want to lodge the PowerShell in a post instead of continuing to rewrite it.

When we are building an Intranet, we often want to grant permissions for all the Communication Sites to a small set of people during the testing process. This script will do that for one user.

Here is a quick overview of what is happening:

  • Everything before line 12 is just set up. I define a few variables pointing to the Admin Site and Hub Site. I connect to the Admin Site in line 9, and then I have a token which is reusable for all the other connections.
  • In line 12, I get all the sites which are associated with the Hub Site. In this case, it is the root site in the tenant and also a Home Site. This is the most common setup for an Intranet.
  • Next I loop through all the associated sites. The first step is to connect to each site.
  • I’m finding both the Owners and Members groups in lines 16-17. We may want to make some people Owners and other People Members, and we can use the appropriate group in line 18.
  • Communication Sites don’t have backing Microsoft 365 Groups, so I can use the Add-PnPGroupMember cmdlet, which just adds the user(s) to the correct SharePoint group.
  • You could duplicate line 18 to grant permissions to more than one person.
# Import modules
Import-Module PnP.PowerShell

# Base variables
$adminUrl = "https://tenant-admin.sharepoint.com/"
$HubSiteURL = "https://tenant.sharepoint.com/"

# Connect to the tenant
Connect-PnPOnline -Url $adminUrl -Interactive

# Get the sites associated with the Intranet Hub Site
$associatedSites = Get-PnPHubSiteChild -Identity $HubSiteURL | Sort-Object 

foreach ($site in $associatedSites) {
    Connect-PnPOnline -Url $site -Interactive
    $ownerGroup = (Get-PnPSiteGroup | Where-Object { $_.LoginName -like "*Owner*" })[0]
    $memberGroup = (Get-PnPSiteGroup | Where-Object { $_.LoginName -like "*Member*" })[0]
    Add-PnPGroupMember -LoginName "lester.frogbottom@tenant.com" -Group $ownerGroup.LoginName

}

I know the best way to do this is by using a Microsoft 365 Group, but this down and dirty approach makes sense in a limited way. When we launch the Intranet, we’ll clean out all the Members and Visitors to start fresh, so it doesn’t matter that much if we are a bit messy for now. Plus, we may be granting temporary Member permissions to someone just during the build phase.


Eagle-eyed reader Brian McCullough (@bpmccullough) pointed out I was working too hard to get the Member and Owner groups.

Get-PnPGroup -AssociatedOwnerGroup -AssociatedMemberGroup -AssociatedVisitorGroup would these work instead?

— Brian McCullough (@bpmccullough) September 30, 2021

Rather than these two lines:

$ownerGroup = (Get-PnPSiteGroup | Where-Object { $_.LoginName -like "*Owner*" })[0]
$memberGroup = (Get-PnPSiteGroup | Where-Object { $_.LoginName -like "*Member*" })[0]

We can do this:

$ownerGroup = Get-PnPGroup -AssociatedOwnerGroup
$memberGroup = Get-PnPGroup -AssociatedMemberGroup
❌
❌