| Have you ever updated some properties for a user in SharePoint Online, waited what seemed like a reasonable length of time, then did a search for that user? Have you ever done that and found your old values still there? Gah! Me too! Did you then use Mikael Svenson’s brilliant script to fix that? Yeah, me too. A few times. I’m also a tinkerer, and I love me some PowerShell. So I cracked open VS Code, put on some aggressive music, loaded up Mikael’s code, and looked around a bit. I found a couple of things that I’d do differently. Not that there’s anything wrong with his code, it’s good stuff. But I wanted to so some stuff like turn his code into a function, so I could add it to an existing module I have. I wanted to move the JSON file from the root of the web to the Shared Documents library, innocent little changes like that. And his code had been written several years ago. PowerShell has matured since then, as coders we’ve matured, so I cleaned a few other little things up while I was in there. It was fun, and it kept me off the streets for a while. After I got done I reached out to Mikael to see if he was okay with me sharing it with all of you. Mikael, being the stand up guy that he is, was totally good with it. Then I reached out to Paul Bullock about getting it published to the PnP Script Samples. I’ve been meaning to start submitting some stuff to that, but I didn’t have anything I thought was worthy. Paul graciously accepted my script, so now you can all enjoy it too. While you’re there, check out all the other great script samples. There’s some gold in those hills. tk ShortURL: https://www.toddklindt.com/PoshReindexUPS |
| As an M365 Admin it’s not tough to see that the future is hidden somewhere in the Microsoft Graph. And this particular M365 Admin would like to get there using his favorite tool, PowerShell. Microsoft released the Graph SDK, but it didn’t really scratch the itch for me. I found it confusing and really tough to use. But, as much as I ignored it, the Graph didn’t go away. With increased frequency there would be some little tidbit of information I’d want and the answer would be, “It’s in the Graph.” Grrrrr. I was poking the M365 PnP Samples and stumbled across this little gem, Authenticate with and call the Microsoft Graph. That looked promising, I can work with that. I fired up PowerShell, loaded the PnP.PowerShell, fired up the Graph Explorer and started working. It went well enough that I wrote a little wrapper function around it and published it to GitHub. It’s called Get-TKPnPGraphURI and it looks like this:  Get-TKPnPGraphURI -uri https://graph.microsoft.com/v1.0/me/ Get-TKPnPGraphURI -uri https://graph.microsoft.com/v1.0/users | select displayName,userPrincipalName,id It uses PnP.PowerShell, so you’ll need that module installed, and you’ll need to connect with Connect-PnPOnline. From there you can pass it any Graph endpoint, v1.0 or beta. You can find out what the endpoints are at the Graph Explorer. Right now this only supports Gets. You’ll also have to manually put in any parameters or filters. I’ve added help and examples. I hope to expand them both in the future. The output is a stand PSCustomObject, so you can send its output down the pipeline however you’d like. Select-Object, Where-Object, Sort-Object, the world is your oyster. Let me know if this is useful and what you’re doing with it. tk ShortURL: https://www.toddklindt.com/PoshMSGraph |
| As I’ve alluded to before, I spend a lot of time in PowerShell, and most of it in the loving embrace of the PnP.PowerShell module. From time to time I find myself wanting to include logic in my scripts based on who the script is being run as, who connected to Microsoft 365. The majority of my connections where with the good old username and password combination. When that’s the case, I could use this to find how who I had connected as:
Connect-PnPOnline -Url https://m365x995492.sharepoint.com/ admin@M365x995492.onmicrosoft.com ((Get-PnPConnection).PSCredential).username
It looks like this:
That worked great, right up to the point where I didn’t just log in with username credentials. For instance, the Sympraxis tenant requires MFA so I have to connect with the –Interactive parameter:
Old Faithful let me down. Back to the drawing board. Poking around the Internet I saw some smart folks were using this method:
$ctx = Get-PnPContext $ctx.Load($ctx.Web.CurrentUser) $ctx.ExecuteQuery() $ctx.Web.CurrentUser $UPN = $ctx.Web.CurrentUser.Email
I haven’t tried it yet with Certificate authentication, so I’m not sure how it reports that. The good news is that method also works with a username and password login:
I’ve created a Function, Get-TKPnPCurrentUser, to make that short and easy to use. I've added it to the TKM365Commands module I published in GitHub.
tk
ShortURL: https://www.toddklindt.com/POSHGetCurrentPnPUser
edit 4/15/22 - Added link to GitHub |
| Like mixing chocolate and peanut butter I’m mixing two of my favorite things, SysKit and Sympraxis, in one magnificent webinar. Toni, Marc, Derek, and I will be talking about how technology can improve business and how we work with customers. And then, we’ll spend a lot of time with the mics open metaphorically and taking questions from the audience. So if you want to stump Marc or Derek, now is your chance. The magic happens Wednesday, Sep 22 at 10:00 am CDT. Please sign up free and join us. We’d love to have you. tk |
| I recently got a fun email from a client. Their tenant had run over its storage allocation. After some quick investigation they realized that they had a few dozen files with a lot of versions, the average number of versions was over 1200 per file. Some files had over 3000 versions. The kicker is that these files were big, dozens or hundreds of MBs each. Thousands of files, 100s of MBs a piece? Pretty soon you’re talking about some real storage space! His question to me was how he could easily delete these unneeded versions quickly and easily? PowerShell, of course!
Before we get to the golden PowerShell nuggets at the end, how did they end up here in the first place? Versions have been around forever in SharePoint but in the last couple of years there was a change. As part of their strategy to protect us from ransomware, Microsoft turned on versioning for all Document Libraries in SPO, and set the maximum number of versions to 500. That way if malware encrypted your site you could just roll your documents back to an unencrypted version. Pretty clever approach. The bad news is that if you don’t know this is in place, like my customer didn’t, you can chew up a lot of space by frequently uploading large files. Back to our story…
My mancrush on the PnP PowerShell is well documented, so of course that’s the first place I looked for a solution. Sure enough, there it was, shining like a beacon of hope on a foggy night, Remove-PnPFileVersion. Do you hear angels singing? I know I do. I sent that off to my customer, dusted off my hands, and leaned back, put my hands behind my head and basked in self satisfaction. It was glorious. Until the customer replied…
He didn’t want to delete all of the versions of a file. He wanted to something like delete all except the last 5 versions. Remove-PnPFileVersion has 2 parameter sets. One that deletes a single version (by its ID number) and one that deletes all versions. No middle ground. While my previous victory was short lived, I knew PowerShell would come through for me here too.
Long story short, I scribbled down a quick PowerShell script that will delete the file versions beyond the number you wish to keep. This customer had a CSV file of the files they wanted to prune, so I added support for that. I have posted the files on GitHub (DeleteOldVersions.ps1 and VersionDelete.csv ) and I’ll go over the mechanics here.
Here is what the CSV file looks like, if you want to use that:

The Remove-PnPFileVersion cmdlet natively handles URLs in the forms of lines 2-5. I added support for the URL having the tenant name as well because a report the customer had included that and I wanted to make it easy for them to read. I’m good like that.
Here’s the code:
First I connect to the site:
You’ll need to adjust those to your own situation. Then I load up the CSV file. If you don’t want to do this with a CSV file you don’t have to. You can manually put the file name in.
$FileList = Import-Csv .\VersionDelete.csv
Then I pick how many file versions I want to keep:
$VersionsToKeep = 5
Next I walk through the $FileList.
foreach($File in $FileList) {
If the FileName property has the $SiteUrl in it I take it out.
$Filename = $File.FileName.Replace($SiteUrl,"")
Then I grab all the FileVersions of the file:
$FileVersions = Get-PnPFileVersion -Url $Filename
Get-PnPFileVersion does not show the Current Version, so it will always show one fewer version than what you see in the UI. If the number of versions is greater than the version we said we wanted to keep in $VersionsToKeep then I create a list of versions in $DeleteVersionList to delete:
if ($FileVersions.Count -gt $VersionsToKeep) { $DeleteVersionList = ($FileVersions[0..$($FileVersions.Count - $VersionsToKeep)])
With that list in hand I walk through it and run Remove-PnPFileVersion against it. In the code in GitHub I have commented out the line (Line 33 as of 9/14/21) that actually deletes the version. You’ll have to uncomment that to do anything.
foreach($VersionToDelete in $DeleteVersionList) { Remove-PnPFileVersion -Url $Filename -Identity $VersionToDelete.Id –Force
To make piping easy I output the versions deleted as a CustomObject:
$Output = [PSCustomObject]@{ PSTypeName = 'TKDeletedFileVersion' Filename = $Filename DeletedVersion = $($VersionToDelete.VersionLabel) }
$Output
When you look at the code you’ll notice a line at the top, $VerbosePreference = "Continue"
If you uncomment that line it will light up the Write-Verbose statements in the code. Set it back to SilentyContinue to make them go away.
This is what it looks like with Verbosity off:
If you need to audit which versions are being deleted you can pipe that to Export-CSV and save it to a file.
If you want to create some versions to test this with you can use the following PowerShell:
$DocLib = “Shared Documents” Get-PnPListItem -List $Doclib | select Id,@{l="FileLeafRef";e={$_.FieldValues.FileLeafRef}}
That’ll get you the files in the Document Library. You can use this command to touch them and create 6 new versions:
0..5 | foreach {Set-PnPListItem -List $Doclib -Identity 4 -Values @{"FileLeafRef"="Building materials licences to budget for Storytelling.docx"}}
It is also possible to filter versions by date. That way instead of deleting all but the last 5 versions, you would be able to delete all of the versions older than 30 days. That’s a blog post for a different day though.
tk
ShortURL: https://www.toddklindt.com/PoshDeleteSPOFileVersions
|
| I don’t know if I could pick my favorite blog crash. There have been so many over the years. It would be like picking my favorite kid, or favorite tablet. I just can’t.
This most recent crash was caused by the drive the VM my blog is running on failing. “But what about backups??” I hear you all shouting in unison. I had VM backups in place, but I had moved the VM to a different server and the backup job didn’t follow along. I also had database backups. These were going directly to my NAS device. Unfortunately when I replaced my NAS in December I forgot to point the SQL backup job to the new location. My only backup was a lonely database backup from December. So, with my SharePoint 2010 and SQL 2012 CDs in hand, I reinstalled everything, and here we are.
I was able to republish the blog posts I’ve written since the backup, so there was no loss there. I did lose the comments you’ve left and I’ve responded to. Sorry about that. I’m in the process of putting it all back together, but there are a few things I haven’t finished yet. I’m putting the list here, so we can all keep track of it. If you see something wrong and it’s not on this list, send me a tweet or leave me a comment (I’ll try not to lose it) and I’ll add it to the list.
To Do List: Get Search working Update SharePoint 2010 Builds list (9/10/21) Update SharePoint 2013 Builds list (9/13/21) Go through IIS Logs and look for failed requests for ShortURLs I’ve missed Verify all the http to https redirects work properly Create SQL backup job (9/13/21) Create VM backup job Fix broken images in blog posts
Thanks for sticking around,
tk
ShortURL: https://www.toddklindt.com/FirstBlogCrash2021
|
| Throughout my IT career I have had to create tens (or hundreds, or thousands) of objects to test something. It could be a bunch of Windows Users, a bunch of folders, files, etc. It seems like every time that happens I end up starting from scratch on the process. To stop that silly cycle I decided to make the process official by blogging it. Let’s stop this madness! This time it started with my friend Michal Pisarek posting this tweet:  Orchestry needs to test their lifecycle features and he wanted to stress test it real good! As is often the case, I see a tweet like that and my first thought is “Challenge Accepted!” The mechanics of how to create Teams with PowerShell is pretty simple but where this really gets tricky, at least for me, is the names. Especially if you’re looking at creating 20,000 like Michal is. In the past the way I’ve handled that is the old tried and true “Adjective Noun Number” formula. To get near 20,000 I wanted a long list of nouns and adjectives to pull from. I scoured the Internet and pulled together two files, nouns.txt and adjectives.txt. You can find them in this GitHub repo. Then I tack a random two digit number at the end to reduce the chance of collision. I put those files in the same directory as this PowerShell script and let ‘er rip! Connect-PnPOnline -Url https://CONTOSO-admin.sharepoint.com -Interactive # import the files with nouns and adjectives $Nouns = Get-Content .\nouns.txt $Adjectives = Get-Content .\adjectives.txt # Number of Teams to create $NumberOfTeams = 3 $Index = 1 while ($Index -le $NumberOfTeams) { # Generate Random stuff $TeamNoun = $Nouns | Get-Random $TeamAdjective = $Adjectives | Get-Random $TeamNumber = Get-Random -Maximum 100 $TeamDisplayName = "$TeamAdjective $TeamNoun $TeamNumber" Write-Host "$Index - $TeamDisplayName" New-PnPTeamsTeam -DisplayName $TeamDisplayName -MailNickName $($TeamDisplayName.Replace(" ","")) -Description $TeamDisplayName -Visibility Public -AllowGiphy $true $Index++ } You can find the file CreateLotsofTeams.ps1 in that same GitHub repo. You can alter the nouns and adjectives files as you see fit. Set the the $NumberofTeams variable to how many Teams you want and you’re set. This script uses the venerable PnP.PowerShell module. You’ll need that installed and its Azure Application registered before you can run this. Be sure to change the Connect-PnPOnline line to reflect your tenant’s name, unless you actually work for Contoso. Because of some weird timing, the current version of the PnP.PowerShell, 1.6.0, won’t work with this script as there is a bug in New-PnPTeamsTeam that prevents it from actually creating a Team. Ironic, I know. I put notes in the CreateLotsofTeams.ps1 file on how to handle that. But if you’re running it and it looks successful but no Teams are being created, look there first. Also, for whatever reason, when you look at the Groups Sites in SharePoint they don’t show up as being Teams enabled, but they really are.  You can see in this crudely mocked up screenshot that the Teams are in the Teams client even though SharePoint Admin Center swears they don’t exist. And while this script’s purpose in life is to create lots and lots of Teams, it could be easily modified to create lots and lots of anything. If you just need Groups, swap out New-PnPTeamsTeam with New-PnPMicrosoft365Group. If you just need SharePoint sites, use New-PnPTenantSite. Folders? Add-PnPFolder. I think you see where I’m going with this. If you’re like Michal and you’re going to create 20,000 Teams, or anything, I hope you have a comfortable chair. It’s going to take a while. Michal is seeing about 1 Team a minute. It’s going to take him a couple of weeks at that pace. Almost certainly PowerShell is the bottleneck in this situation. If you’re looking at a similar situation, my advice is to open up another PowerShell window and run another instance of CreateLotsofTeams.ps1 there. And maybe run a few instances on another machine entirely. In the past that has helped me speed this things up considerably. Enjoy. tk ShortURL: https://www.toddklindt.com/PoshLotsandLotsofTeams |
| I’ve done a few articles about the new PnP.PowerShell module. One of the biggest changes from its ancestor, SharePointPnPPowerShellOnline, is that it requires the registration of an Azure Application before you can connect with it. In this blog post I’m going to explain how to get that Azure App registered if you’re not a Tenant Admin in your tenant. You don’t need to be a Tenant Admin to use the PnP.PowerShell cmdlets. You don’t even need to be a SharePoint Admin or a site collection admin. There are plenty of cmdlets you can run, like Add-PnPFile if you’re only a Member of the site. However, before you can run the most import PnP cmdlet of all, Connect-PnPOnline, the PnP Azure Application has to be registered in your tenant by a tenant admin. If it’s not, you’ll get a sad message that looks like this:  Here’s the text: Connect-PnPOnline: AADSTS65001: The user or administrator has not consented to use the application with ID '31359c7f-bd7e-475c-86db-fdb8c937548e' named 'PnP Management Shell'. Send an interactive authorization request for this user and resource. In most cases the person introducing the PnP.PowerShell module is a tenant admin, so it’s not an issue. They run Register-PnPManagementShellAccess and Bob’s your uncle. But it’s not uncommon for an organization to be large enough that the SharePoint or Microsoft 365 Admin team is not a tenant admin. In that case the Tenant Admin, who likely doesn’t know what a PnP.PowerShell is, has to register the Azure App before the SharePoint Admin can enjoy the bliss that is PnP.PowerShell. Fortunately, there’s an easy enough solution, the Consent URL. The Consent URL is the URL to a web page your Tenant Admin can go to to consent the PnP.PowerShell Azure App without needing to install anything, or really know anything about the PnP.PowerShell. There are a few ways to get the Consent URL. It doesn’t matter how you do it, they all get you to the same place. The easiest way to remember is to run Register-PnPManagementShellAccess –ShowConsentUrl after installing the PnP.PowerShell. You’ll be asked to log in, but you don’t need to be an sort of admin. It’s only logging in so it knows when tenant you’re in. Then it will give you the Consent URL. It looks like this:  https://login.microsoftonline.com/651c433d-d221-4bb3-ac77-392f4bf06a6b/adminconsent?client_id=31359c7f-bd7e-475c-86db-fdb8c937548e The part in the red box is your tenant’s ID.You had to log so the cmdlet could get that number. The Client_id refers to the PnP.PowerShell, so it’s the same everywhere. You can also specify your tenant’s name instead of its ID. This works as well: https://login.microsoftonline.com/tenantname.onmicrosoft.com/adminconsent?client_id=31359c7f-bd7e-475c-86db-fdb8c937548e Whether you get the URL from running Register-PnPManagementShellAccess –ShowConsentUrl or by copying it out of this blog post and putting your tenant’s information, send that URL to your Tenant Admin. When they browse to the page it will look like this:  All they need to do is click Accept and you’re ready to go. That’s a pretty long, scary list of permissions, and it might spook some admins. Accepting this does not give everyone in your tenant all of those permissions. The PnP.PowerShell Azure App uses Delegation, which means any user using it to access objects in Microsoft 365 has to have permission to access it. The PnP.PowerShell does not allow anyone access to anything they don’t otherwise have access to. If they don’t believe you, have them try. Have someone that cannot open up a SharePoint site in the browser try to connect to it with Connect-PnPOnline. They won’t be able to. If they want to check out what the Azure App has permission to, or heaven forbid, remove it, you can browse to the Azure AD Portal and find it in the Enterprise Applications.  The Permissions blade will show you all delegated permissions the app has. Feel free to poke around, but resist the urge to change any, even if you’re positive you’ll never use them. I promise it’ll only hurt you in the future. After your tenant admin has done all of that you should be able to get back to all that PowerShell and PnP goodness. tk ShortURL: https://www.toddklindt.com/PoshRegisterPnP |
Compliance Details javascript:commonShowModalDialog('{SiteUrl}/_layouts/itemexpiration.aspx?ID={ItemId}&List={ListId}', 'center:1;dialogHeight:500px;dialogWidth:500px;resizable:yes;status:no;location:no;menubar:no;help:no', function GotoPageAfterClose(pageid){if(pageid == 'hold') {STSNavigate(unescape(decodeURI('{SiteUrl}'))+'/_layouts/hold.aspx?ID={ItemId}&List={ListId}'); return false;} if(pageid == 'audit') {STSNavigate(unescape(decodeURI('{SiteUrl}'))+'/_layouts/Reporting.aspx?Category=Auditing&backtype=item&ID={ItemId}&List={ListId}'); return false;} if(pageid == 'config') {STSNavigate(unescape(decodeURI('{SiteUrl}'))+'/_layouts/expirationconfig.aspx?ID={ItemId}&List={ListId}'); return false;}}, null); 0x0 0x1 ContentType 0x01 898 View in Browser /blog/_layouts/xlviewer.aspx?id={ItemUrl}&DefaultItemOpen=1 0x0 0x1 FileType xlsx 255 View in Browser /blog/_layouts/xlviewer.aspx?id={ItemUrl}&DefaultItemOpen=1 0x0 0x1 FileType xlsm 255 View in Browser /blog/_layouts/xlviewer.aspx?id={ItemUrl}&DefaultItemOpen=1 0x0 0x1 FileType xlsb 255 View in Browser /blog/_layouts/xlviewer.aspx?id={ItemUrl}&DefaultItemOpen=1 0x0 0x1 FileType ods 255 |
|
|
|
|
|
| There are no items to show in this view of the "Events" list. |
|
Compliance Details javascript:commonShowModalDialog('{SiteUrl}/_layouts/itemexpiration.aspx?ID={ItemId}&List={ListId}', 'center:1;dialogHeight:500px;dialogWidth:500px;resizable:yes;status:no;location:no;menubar:no;help:no', function GotoPageAfterClose(pageid){if(pageid == 'hold') {STSNavigate(unescape(decodeURI('{SiteUrl}'))+'/_layouts/hold.aspx?ID={ItemId}&List={ListId}'); return false;} if(pageid == 'audit') {STSNavigate(unescape(decodeURI('{SiteUrl}'))+'/_layouts/Reporting.aspx?Category=Auditing&backtype=item&ID={ItemId}&List={ListId}'); return false;} if(pageid == 'config') {STSNavigate(unescape(decodeURI('{SiteUrl}'))+'/_layouts/expirationconfig.aspx?ID={ItemId}&List={ListId}'); return false;}}, null); 0x0 0x1 ContentType 0x01 898 View in Browser /blog/_layouts/xlviewer.aspx?id={ItemUrl}&DefaultItemOpen=1 0x0 0x1 FileType xlsx 255 View in Browser /blog/_layouts/xlviewer.aspx?id={ItemUrl}&DefaultItemOpen=1 0x0 0x1 FileType xlsm 255 View in Browser /blog/_layouts/xlviewer.aspx?id={ItemUrl}&DefaultItemOpen=1 0x0 0x1 FileType xlsb 255 View in Browser /blog/_layouts/xlviewer.aspx?id={ItemUrl}&DefaultItemOpen=1 0x0 0x1 FileType ods 255 |
|
|
|