From 3f7ee2a87896df0f59cb62aa1a48b4f1862bcd53 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Thu, 9 Jul 2026 16:02:12 +0200 Subject: [PATCH 1/7] Refactor: split helper module into SPSCleanDependencies.Common (Public/Private + manifest) Introduce src/Modules/SPSCleanDependencies.Common aligned on the SPSUpdate pattern: one function per file under Public/ (Get-SPSInstalledProductVersion, Get-SPSMissingServerDependencies and the six Remove-SPS* cleanups) and the internal SQL helpers under Private/ (Get-SQLMissing*). A loader .psm1 dot-sources Private + Public and exports only the public functions, and a clean .psd1 manifest (ModuleVersion 1.3.0, correct FunctionsToExport) replaces the mis-named, never-imported .util.util.psd1. The former module-level classes, ArrayLists and jsonObject are folded into Get-SPSMissingServerDependencies (their only consumer) so the module holds no mutable state, and the import-time prelude is dropped from the module (relocated to the entry script in a follow-up commit) making the module import-safe. Refs #6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Private/Get-SQLMissingAssemblyInfo.ps1 | 50 +++ .../Private/Get-SQLMissingConfiguration.ps1 | 38 +++ .../Private/Get-SQLMissingSetupFileInfo.ps1 | 52 ++++ .../Private/Get-SQLMissingWebPartInfo.ps1 | 57 ++++ .../Public/Get-SPSInstalledProductVersion.ps1 | 13 + .../Get-SPSMissingServerDependencies.ps1 | 289 ++++++++++++++++++ .../Public/Remove-SPSMissingAssembly.ps1 | 119 ++++++++ .../Public/Remove-SPSMissingConfiguration.ps1 | 59 ++++ .../Public/Remove-SPSMissingFeature.ps1 | 59 ++++ .../Public/Remove-SPSMissingSetupFile.ps1 | 63 ++++ .../Public/Remove-SPSMissingWebPart.ps1 | 97 ++++++ .../Public/Remove-SPSOrphanedSite.ps1 | 35 +++ .../SPSCleanDependencies.Common.psd1 | 35 +++ .../SPSCleanDependencies.Common.psm1 | 25 ++ 14 files changed, 991 insertions(+) create mode 100644 src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingAssemblyInfo.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingConfiguration.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingSetupFileInfo.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingWebPartInfo.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Public/Get-SPSInstalledProductVersion.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Public/Get-SPSMissingServerDependencies.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingAssembly.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingConfiguration.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingFeature.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingSetupFile.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingWebPart.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSOrphanedSite.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/SPSCleanDependencies.Common.psd1 create mode 100644 src/Modules/SPSCleanDependencies.Common/SPSCleanDependencies.Common.psm1 diff --git a/src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingAssemblyInfo.ps1 b/src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingAssemblyInfo.ps1 new file mode 100644 index 0000000..e3b3d87 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingAssemblyInfo.ps1 @@ -0,0 +1,50 @@ +function Get-SQLMissingAssemblyInfo { + param + ( + [Parameter()] + [System.String] + $DatabaseName, + + [Parameter()] + [System.String] + $DatabaseServer, + + [Parameter()] + [System.String] + $AssemblyInfo + ) + + class SQLMissingSetupFileInfo { + [System.String]$AssemblyID + [System.String]$SiteID + [System.String]$WebId + [System.String]$HostId + [System.String]$HostType + } + $tbSQLmissingAssemblies = New-Object -TypeName System.Collections.ArrayList + try { + $sqlQuery = + @" +USE $($DatabaseName) +Select Id, SiteID, WebID, HostType, hostId +FROM EventReceivers (NOLOCK) where Assembly = '$($AssemblyInfo)' +"@ + + $invokeSQLQueries = Invoke-Sqlcmd -Query $sqlQuery ` + -ServerInstance "$($DatabaseServer)" + + foreach ($invokeSQLQuery in $invokeSQLQueries) { + [void]$tbSQLmissingAssemblies.Add([SQLMissingSetupFileInfo]@{ + AssemblyID = $invokeSQLQuery.id; + SiteID = $invokeSQLQuery.SiteID; + WebId = $invokeSQLQuery.WebId; + HostId = $invokeSQLQuery.HostId; + HostType = $invokeSQLQuery.HostType; + }) + } + } + catch { + return $_ + } + return $tbSQLmissingAssemblies +} diff --git a/src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingConfiguration.ps1 b/src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingConfiguration.ps1 new file mode 100644 index 0000000..ce58215 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingConfiguration.ps1 @@ -0,0 +1,38 @@ +function Get-SQLMissingConfiguration { + param + ( + [Parameter()] + [System.String] + $DatabaseName, + + [Parameter()] + [System.String] + $DatabaseServer + ) + + class SQLMissingConfigurationInfo { + [System.String]$SiteID + [System.String]$Login + } + $tbSQLmissingConfigurations = New-Object -TypeName System.Collections.ArrayList + try { + $sqlQuery = + @" +USE $($DatabaseName) +SELECT [tp_SiteID],[tp_Login] FROM [UserInfo] WITH (NOLOCK) WHERE tp_IsActive = 1 AND tp_SiteAdmin = 1 AND tp_Deleted = 0 and tp_Login not LIKE 'i:%' +"@ + $invokeSQLQueries = Invoke-Sqlcmd -Query $sqlQuery ` + -ServerInstance "$($DatabaseServer)" + + foreach ($invokeSQLQuery in $invokeSQLQueries) { + [void]$tbSQLmissingConfigurations.Add([SQLMissingConfigurationInfo]@{ + SiteID = $invokeSQLQuery.tp_SiteID; + Login = $invokeSQLQuery.tp_Login; + }) + } + } + catch { + return $_ + } + return $tbSQLmissingConfigurations +} diff --git a/src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingSetupFileInfo.ps1 b/src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingSetupFileInfo.ps1 new file mode 100644 index 0000000..5af9eb7 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingSetupFileInfo.ps1 @@ -0,0 +1,52 @@ +function Get-SQLMissingSetupFileInfo { + param + ( + [Parameter()] + [System.String] + $DatabaseName, + + [Parameter()] + [System.String] + $DatabaseServer, + + [Parameter()] + [System.String] + $SetupPath + ) + + class SQLMissingSetupFileInfo { + [System.String]$FileID + [System.String]$SiteID + [System.String]$DirName + [System.String]$LeafName + [System.String]$WebId + [System.String]$ListId + } + $tbSQLmissingSetupFiles = New-Object -TypeName System.Collections.ArrayList + try { + $sqlQuery = + @" +USE $($DatabaseName) +SELECT id, SiteID, DirName, LeafName, WebId, ListId +FROM AllDocs (NOLOCK) where SetupPath = '$($SetupPath)' +"@ + + $invokeSQLQueries = Invoke-Sqlcmd -Query $sqlQuery ` + -ServerInstance "$($DatabaseServer)" + + foreach ($invokeSQLQuery in $invokeSQLQueries) { + [void]$tbSQLmissingSetupFiles.Add([SQLMissingSetupFileInfo]@{ + FileID = $invokeSQLQuery.id; + SiteID = $invokeSQLQuery.SiteID; + DirName = $invokeSQLQuery.DirName; + LeafName = $invokeSQLQuery.LeafName; + WebId = $invokeSQLQuery.WebId; + ListId = $invokeSQLQuery.ListId; + }) + } + } + catch { + return $_ + } + return $tbSQLmissingSetupFiles +} diff --git a/src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingWebPartInfo.ps1 b/src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingWebPartInfo.ps1 new file mode 100644 index 0000000..f709b20 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Private/Get-SQLMissingWebPartInfo.ps1 @@ -0,0 +1,57 @@ +function Get-SQLMissingWebPartInfo { + param + ( + [Parameter()] + [System.String] + $DatabaseName, + + [Parameter()] + [System.String] + $DatabaseServer, + + [Parameter()] + [System.String] + $ClassID + ) + + class SQLMissingWebPartInfo { + [System.String]$StorageKey + [System.String]$ClassName + [System.String]$SiteID + [System.String]$WebID + [System.String]$ListID + [System.String]$DirName + [System.String]$LeafName + } + $tbSQLmissingWebParts = New-Object -TypeName System.Collections.ArrayList + try { + $sqlQuery = + @" +USE $($DatabaseName) +SELECT wp.tp_ID, d.SiteId, d.WebId, d.ListId, d.DirName, d.LeafName, wp.tp_Class +FROM AllDocs d WITH (NOLOCK) +INNER JOIN AllWebParts wp WITH (NOLOCK) ON wp.tp_PageUrlID = d.Id +INNER JOIN AllWebs w WITH (NOLOCK) ON d.WebId = w.id +WHERE wp.tp_WebPartTypeId = '$($ClassID)' +"@ + + $invokeSQLQueries = Invoke-Sqlcmd -Query $sqlQuery ` + -ServerInstance "$($DatabaseServer)" + + foreach ($invokeSQLQuery in $invokeSQLQueries) { + [void]$tbSQLmissingWebParts.Add([SQLMissingWebPartInfo]@{ + StorageKey = $invokeSQLQuery.tp_ID; + ClassName = $invokeSQLQuery.tp_Class; + SiteID = $invokeSQLQuery.SiteId; + WebID = $invokeSQLQuery.WebId; + ListID = $invokeSQLQuery.ListId; + DirName = $invokeSQLQuery.DirName; + LeafName = $invokeSQLQuery.LeafName; + }) + } + } + catch { + return $_ + } + return $tbSQLmissingWebParts +} diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Get-SPSInstalledProductVersion.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Get-SPSInstalledProductVersion.ps1 new file mode 100644 index 0000000..f5c87d7 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Public/Get-SPSInstalledProductVersion.ps1 @@ -0,0 +1,13 @@ +function Get-SPSInstalledProductVersion { + [OutputType([System.Version])] + param () + + $pathToSearch = 'C:\Program Files\Common Files\microsoft shared\Web Server Extensions\*\ISAPI\Microsoft.SharePoint.dll' + $fullPath = Get-Item $pathToSearch -ErrorAction SilentlyContinue | Sort-Object { $_.Directory } -Descending | Select-Object -First 1 + if ($null -eq $fullPath) { + throw 'SharePoint path {C:\Program Files\Common Files\microsoft shared\Web Server Extensions} does not exist' + } + else { + return (Get-Command $fullPath).FileVersionInfo + } +} diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Get-SPSMissingServerDependencies.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Get-SPSMissingServerDependencies.ps1 new file mode 100644 index 0000000..27aa828 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Public/Get-SPSMissingServerDependencies.ps1 @@ -0,0 +1,289 @@ +class SPMissingFeaturesInfo { + [System.String]$Database + [System.String]$Category + [System.String]$FeatureID + [System.String]$Message + [System.String]$Remedy + [System.String]$SiteID + [System.String]$Path +} +class SPMissingWebPartInfo { + [System.String]$Database + [System.String]$Category + [System.String]$WebPartID + [System.String]$Message + [System.String]$Remedy + [System.String]$ClassName + [System.String]$StorageKey + [System.String]$SiteID + [System.String]$WebID + [System.String]$ListID + [System.String]$DirName + [System.String]$LeafName +} +class SPMissingSetupFileInfo { + [System.String]$Database + [System.String]$Category + [System.String]$Message + [System.String]$Remedy + [System.String]$SetupPath + [System.String]$FileID + [System.String]$SiteID + [System.String]$WebID +} +class SPMissingAssemblyInfo { + [System.String]$Database + [System.String]$Category + [System.String]$Message + [System.String]$Remedy + [System.String]$AssemblyInfo + [System.String]$AssemblyID + [System.String]$HostID + [System.String]$HostType + [System.String]$SiteID + [System.String]$WebID +} +class SPMissingConfigurationInfo { + [System.String]$Database + [System.String]$Category + [System.String]$Message + [System.String]$Remedy + [System.String]$SiteID + [System.String]$Login +} +class SPMissingSiteDefinition { + [System.String]$Database + [System.String]$Category + [System.String]$Message + [System.String]$Remedy +} +class SPMissingOrphanedSites { + [System.String]$Database + [System.String]$Category + [System.String]$Message + [System.String]$Remedy + [System.String]$SiteID +} + +function Get-SPSMissingServerDependencies { + param + ( + [Parameter()] + [System.String] + $Path + ) + + # Initialize the jSON object and the per-category collections that will be + # serialized to $Path once every content database has been inspected. + $jsonObject = [PSCustomObject]@{} + $tbSPmissingFeatures = New-Object -TypeName System.Collections.ArrayList + $tbSPmissingWebParts = New-Object -TypeName System.Collections.ArrayList + $tbSPmissingSetupFiles = New-Object -TypeName System.Collections.ArrayList + $tbSPmissingAssemblies = New-Object -TypeName System.Collections.ArrayList + $tbSPmissingConfigurations = New-Object -TypeName System.Collections.ArrayList + $tbSPmissingSiteDefinitions = New-Object -TypeName System.Collections.ArrayList + $tbSPmissingOrphanedSites = New-Object -TypeName System.Collections.ArrayList + + Write-Output '-----------------------------------------------' + Write-Output 'Getting SharePoint Content Databases' + try { + $spContentDBs = Get-SPContentDatabase + if ($null -ne $spContentDBs) { + foreach ($spContentDB in $spContentDBs) { + Write-Output " * Testing SharePoint Content Database $($spContentDB.Name)" + $testDbContent = Test-SPContentDatabase $spContentDB -ShowLocation:$true -ExtendedCheck:$true + if ($null -ne $testDbContent) { + $missingFeatures = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'MissingFeature' } + $missingWebParts = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'MissingWebPart' } + $missingSetupFiles = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'MissingSetupFile' } + $missingAssemblies = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'MissingAssembly' } + $missingConfigurations = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'Configuration' } + $missingSiteDefinitions = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'MissingSiteDefinition' } + $missingOrphanedSites = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'SiteOrphan' } + + if ($null -ne $missingFeatures) { + foreach ($missingFeature in $missingFeatures) { + $featureID = ([regex]::Matches($missingFeature.Message, '[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}')).value + [void]$tbSPmissingFeatures.Add([SPMissingFeaturesInfo]@{ + Database = "$($spContentDB.Name)"; + Category = $missingFeature.Category; + FeatureID = $featureID + Message = $missingFeature.Message; + Remedy = $missingFeature.Remedy; + SiteID = $missingFeature.Locations[0].SiteId; + Path = $missingFeature.Locations[0].Path; + }) + } + } + if ($null -ne $missingWebParts) { + foreach ($missingWebPart in $missingWebParts) { + $webPartID = ([regex]::Matches($missingWebPart.Message, '[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}')).value + $sqlMissingWebParts = Get-SQLMissingWebPartInfo -DatabaseName "$($spContentDB.Name)" ` + -DatabaseServer "$($spContentDB.Server)" ` + -ClassID "$($webPartID)" + + if ($null -ne $sqlMissingWebParts -and $sqlMissingWebParts.Count -gt 0) { + foreach ($sqlMissingWebPart in $sqlMissingWebParts) { + [void]$tbSPmissingWebParts.Add([SPMissingWebPartInfo]@{ + Database = "$($spContentDB.Name)"; + Category = $missingWebPart.Category; + WebPartID = $webPartID; + Message = $missingWebPart.Message; + Remedy = $missingWebPart.Remedy; + ClassName = $sqlMissingWebPart.ClassName; + StorageKey = $sqlMissingWebPart.StorageKey; + SiteID = $sqlMissingWebPart.SiteID; + WebID = $sqlMissingWebPart.WebID; + ListID = $sqlMissingWebPart.ListID; + DirName = $sqlMissingWebPart.DirName; + LeafName = $sqlMissingWebPart.LeafName; + }) + } + } + else { + [void]$tbSPmissingWebParts.Add([SPMissingWebPartInfo]@{ + Database = "$($spContentDB.Name)"; + Category = $missingWebPart.Category; + WebPartID = $webPartID; + Message = $missingWebPart.Message; + Remedy = $missingWebPart.Remedy; + }) + } + } + } + if ($null -ne $missingSetupFiles) { + foreach ($missingSetupFile in $missingSetupFiles) { + $setupPathInfo = ([regex]::Matches($missingSetupFile.Message, '(?<=\[)(\w+\\\w+.*?)(?=\])')).value + $sqlMissingSetupFileInfos = Get-SQLMissingSetupFileInfo -DatabaseName "$($spContentDB.Name)" ` + -DatabaseServer "$($spContentDB.Server)" ` + -SetupPath "$($setupPathInfo)" + + foreach ($sqlMissingSetupFileInfo in $sqlMissingSetupFileInfos) { + [void]$tbSPmissingSetupFiles.Add([SPMissingSetupFileInfo]@{ + Database = "$($spContentDB.Name)"; + Category = $missingSetupFile.Category; + Message = $missingSetupFile.Message; + Remedy = $missingSetupFile.Remedy; + SetupPath = $setupPathInfo; + FileID = $sqlMissingSetupFileInfo.FileID; + SiteID = $sqlMissingSetupFileInfo.SiteID; + WebID = $sqlMissingSetupFileInfo.WebID; + }) + } + } + } + if ($null -ne $missingAssemblies) { + foreach ($missingAssembly in $missingAssemblies) { + $assemblyInfo = ([regex]::Matches($missingAssembly.Message, '(?<=\[)(\w+\.\w+.*?)(?=\])')).value + $sqlMissingAssemblies = Get-SQLMissingAssemblyInfo -DatabaseName "$($spContentDB.Name)" ` + -DatabaseServer "$($spContentDB.Server)" ` + -AssemblyInfo "$($assemblyInfo)" + + foreach ($sqlMissingAssembly in $sqlMissingAssemblies) { + [void]$tbSPmissingAssemblies.Add([SPMissingAssemblyInfo]@{ + Database = "$($spContentDB.Name)"; + Category = $missingAssembly.Category; + Message = $missingAssembly.Message; + Remedy = $missingAssembly.Remedy; + AssemblyInfo = $assemblyInfo; + AssemblyID = $sqlMissingAssembly.AssemblyID; + SiteID = $sqlMissingAssembly.SiteID; + WebID = $sqlMissingAssembly.WebID; + HostID = $sqlMissingAssembly.HostID; + HostType = $sqlMissingAssembly.HostType; + }) + } + } + } + if ($null -ne $missingConfigurations) { + foreach ($missingConfiguration in $missingConfigurations) { + if (([regex]::Matches($missingConfiguration.Message, '^(.*?(\bclaims\b)[^$]*)$')).success) { + $sqlMissingConfigurations = Get-SQLMissingConfiguration -DatabaseName "$($spContentDB.Name)" ` + -DatabaseServer "$($spContentDB.Server)" + } + foreach ($sqlMissingConfiguration in $sqlMissingConfigurations) { + [void]$tbSPmissingConfigurations.Add([SPMissingConfigurationInfo]@{ + Database = "$($spContentDB.Name)"; + Category = $missingConfiguration.Category; + Message = $missingConfiguration.Message; + Remedy = $missingConfiguration.Remedy; + SiteID = $sqlMissingConfiguration.SiteID; + Login = $sqlMissingConfiguration.Login; + }) + } + } + } + if ($null -ne $missingSiteDefinitions) { + foreach ($missingSiteDefinition in $missingSiteDefinitions) { + [void]$tbSPmissingSiteDefinitions.Add([SPMissingSiteDefinition]@{ + Database = "$($spContentDB.Name)"; + Category = $missingSiteDefinition.Category; + Message = $missingSiteDefinition.Message; + Remedy = $missingSiteDefinition.Remedy; + }) + } + } + if ($null -ne $missingOrphanedSites) { + foreach ($missingOrphanedSite in $missingOrphanedSites) { + $siteID = ([regex]::Matches($missingOrphanedSite.Message, '[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}')).value + [void]$tbSPmissingOrphanedSites.Add([SPMissingOrphanedSites]@{ + Database = "$($spContentDB.Name)"; + Category = $missingOrphanedSite.Category; + Message = $missingOrphanedSite.Message; + Remedy = $missingOrphanedSite.Remedy; + SiteID = $siteID; + }) + } + } + } + } + Write-Output 'Adding each list object in PsCustomObject jsonObject:' + if ($null -ne $tbSPmissingFeatures) { + Write-Output '* Adding MissingFeature object' + $jsonObject | Add-Member -MemberType NoteProperty ` + -Name MissingFeature ` + -Value $tbSPmissingFeatures + } + if ($null -ne $tbSPmissingWebParts) { + Write-Output '* Adding MissingWebPart object' + $jsonObject | Add-Member -MemberType NoteProperty ` + -Name MissingWebPart ` + -Value $tbSPmissingWebParts + } + if ($null -ne $tbSPmissingSetupFiles) { + Write-Output '* Adding MissingSetupFile object' + $jsonObject | Add-Member -MemberType NoteProperty ` + -Name MissingSetupFile ` + -Value $tbSPmissingSetupFiles + } + if ($null -ne $tbSPmissingAssemblies) { + Write-Output '* Adding MissingAssembly object' + $jsonObject | Add-Member -MemberType NoteProperty ` + -Name MissingAssembly ` + -Value $tbSPmissingAssemblies + } + if ($null -ne $tbSPmissingConfigurations) { + Write-Output '* Adding MissingConfiguration object' + $jsonObject | Add-Member -MemberType NoteProperty ` + -Name Configuration ` + -Value $tbSPmissingConfigurations + } + if ($null -ne $tbSPmissingSiteDefinitions) { + Write-Output '* Adding MissingSiteDefinitions object' + $jsonObject | Add-Member -MemberType NoteProperty ` + -Name MissingSiteDefinition ` + -Value $tbSPmissingSiteDefinitions + } + if ($null -ne $tbSPmissingOrphanedSites) { + Write-Output '* Adding MissingSiteOrphans object' + $jsonObject | Add-Member -MemberType NoteProperty ` + -Name SiteOrphan ` + -Value $tbSPmissingOrphanedSites + } + $jsonObject | ConvertTo-Json | Set-Content -Path $Path -Force + } + } + catch { + return $_ + } +} diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingAssembly.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingAssembly.ps1 new file mode 100644 index 0000000..d9d25f7 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingAssembly.ps1 @@ -0,0 +1,119 @@ +function Remove-SPSMissingAssembly { + [CmdletBinding(SupportsShouldProcess = $true)] + param + ( + [Parameter()] + [System.String] + $Database, + + [Parameter()] + [System.String] + $AssemblyID, + + [Parameter()] + [System.String] + $SiteID, + + [Parameter()] + [System.String] + $WebID, + + [Parameter()] + [System.String] + $HostType, + + [Parameter()] + [System.String] + $HostID + ) + + try { + Write-Output '-----------------------------------------------' + Write-Output 'Removing Missing Setup File Dependencies of:' + Write-Output " * Database: $Database" + Write-Output " * AssemblyID: $AssemblyID" + Write-Output " * SiteID: $SiteID" + Write-Output " * WebID: $WebID" + Write-Output " * HostType: $HostType" + Write-Output " * HostID: $HostID" + Write-Output '-----------------------------------------------' + + switch ($HostType) { + '0' { + Write-Output ' * HostTypeValue: 0 => SPSite' + $site = Get-SPSite -limit all -Identity $siteID + if ($null -ne $site) { + $AssemblyToDelete = $site.EventReceivers | Where-Object -FilterScript { + $_.id -eq $AssemblyID + } + if ($null -ne $AssemblyToDelete) { + Write-Output "Removing AssemblyID $AssemblyID from SPSite object." + if ($PSCmdlet.ShouldProcess("SPSite $SiteID", "Delete AssemblyID $AssemblyID")) { + $AssemblyToDelete.delete() + } + } + else { + Write-Output "AssemblyID $AssemblyID does not exist in SPSite object." + } + $site.dispose() + } + else { + Write-Output "SiteID $SiteID does not exist.`nPlease check this siteID" + } + } + '1' { + Write-Output ' * HostTypeValue: 1 => SPWeb' + $web = Get-SPWeb -Identity $webID -Site $siteID -Limit ALL + if ($null -ne $web) { + $AssemblyToDelete = $web.EventReceivers | Where-Object -FilterScript { + $_.id -eq $AssemblyID + } + if ($null -ne $AssemblyToDelete) { + Write-Output "Removing AssemblyID $AssemblyID from SPWeb object." + if ($PSCmdlet.ShouldProcess("SPWeb $WebID", "Delete AssemblyID $AssemblyID")) { + $AssemblyToDelete.delete() + } + } + else { + Write-Output "AssemblyID $AssemblyID does not exist in SPWeb object." + } + $web.dispose() + } + else { + Write-Output "WebID $WebID does not exist.`nPlease check this WebID" + } + } + '2' { + Write-Output ' * HostTypeValue: 2 => SPList' + $web = Get-SPWeb -Identity $webID -Site $siteID -Limit ALL + if ($null -ne $web) { + $list = $web.lists | Where-Object -FilterScript { $_.id -eq $hostID } + if ($null -ne $list) { + $AssemblyToDelete = $list.EventReceivers | Where-Object -FilterScript { + $_.id -eq $AssemblyID + } + if ($null -ne $AssemblyToDelete) { + Write-Output "Removing AssemblyID $AssemblyID from SPList object." + if ($PSCmdlet.ShouldProcess("SPList $hostID", "Delete AssemblyID $AssemblyID")) { + $AssemblyToDelete.delete() + } + } + else { + Write-Output "AssemblyID $AssemblyID does not exist in SPList object." + } + $web.dispose() + } + else { + Write-Output "List with host $hostID does not exist.`nPlease check this hostID" + } + } + else { + Write-Output "WebID $WebID does not exist.`nPlease check this WebID" + } + } + } + } + catch { + return $_ + } +} diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingConfiguration.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingConfiguration.ps1 new file mode 100644 index 0000000..5d062e4 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingConfiguration.ps1 @@ -0,0 +1,59 @@ +function Remove-SPSMissingConfiguration { + [CmdletBinding(SupportsShouldProcess = $true)] + param + ( + [Parameter()] + [System.String] + $Database, + + [Parameter()] + [System.String] + $SiteID, + + [Parameter()] + [System.String] + $Login + ) + + try { + Write-Output '-----------------------------------------------' + Write-Output 'Removing Missing Configuration Dependencies of:' + Write-Output " * Database: $Database" + Write-Output " * SiteID: $SiteID" + Write-Output " * Login: $Login" + Write-Output '-----------------------------------------------' + + $site = Get-SPSite -limit all -Identity $siteID + if ($null -ne $site) { + Write-Output "Checking SPSite:" $site.Url + $webs = Get-SPWeb -Site $siteID -Limit ALL + if ($null -ne $webs) { + foreach ($web in $webs) { + if ($web.SiteAdministrators.UserLogin -contains $Login) { + Write-Output "$Login exists in SiteAdministrators" + Write-Output "Removing login: $Login" + Write-Output "of SiteAdministrator Property of SPWeb:" + Write-Output "$($web.Url)" + if ($PSCmdlet.ShouldProcess($web.Url, "Remove SiteAdministrator $Login")) { + $web.SiteAdministrators.Remove($Login) + } + } + else { + Write-Output "$Login does not exist in SiteAdministrators Property" + } + $web.Dispose() + } + $site.Dispose() + } + else { + Write-Output "No SPWeb Object for this SiteID $SiteID" + } + } + else { + Write-Output "SiteID $SiteID does not exist.`nPlease check this siteID" + } + } + catch { + return $_ + } +} diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingFeature.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingFeature.ps1 new file mode 100644 index 0000000..5169191 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingFeature.ps1 @@ -0,0 +1,59 @@ +function Remove-SPSMissingFeature { + [CmdletBinding(SupportsShouldProcess = $true)] + param + ( + [Parameter()] + [System.String] + $Database, + + [Parameter()] + [System.String] + $FeatureID, + + [Parameter()] + [System.String] + $SiteID + ) + + try { + Write-Output '-----------------------------------------------' + Write-Output 'Removing Missing Feature Dependencies of:' + Write-Output " * Database: $Database" + Write-Output " * FeatureID: $FeatureID" + Write-Output " * SiteID: $SiteID" + Write-Output '-----------------------------------------------' + #Display site information + $site = Get-SPSite $SiteID -ErrorAction SilentlyContinue + if ($null -ne $site) { + Write-Output "Checking SPSite:" $site.Url + #Remove the feature from all subsites + ForEach ($web in $Site.AllWebs) { + if ($web.Features[$featureID]) { + Write-Output "`nFound Feature $featureID in web:"$Web.Url"`nRemoving feature" + if ($PSCmdlet.ShouldProcess($Web.Url, "Remove feature $featureID")) { + $web.Features.Remove($featureID, $true) + } + } + else { + Write-Output "`nDid not find feature $featureID in web:" $Web.Url + } + } + #Remove the feature from the site collection + if ($Site.Features[$featureID]) { + Write-Output "`nFound feature $featureID in site:"$site.Url"`nRemoving Feature" + if ($PSCmdlet.ShouldProcess($site.Url, "Remove feature $featureID")) { + $site.Features.Remove($featureID, $true) + } + } + else { + Write-Output "Did not find feature $featureID in site:" $site.Url + } + } + else { + Write-Output "SiteID $SiteID does not exist.`nPlease check this siteID" + } + } + catch { + return $_ + } +} diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingSetupFile.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingSetupFile.ps1 new file mode 100644 index 0000000..bca34d6 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingSetupFile.ps1 @@ -0,0 +1,63 @@ +function Remove-SPSMissingSetupFile { + [CmdletBinding(SupportsShouldProcess = $true)] + param + ( + [Parameter()] + [System.String] + $Database, + + [Parameter()] + [System.String] + $FileID, + + [Parameter()] + [System.String] + $SiteID, + + [Parameter()] + [System.String] + $WebID + ) + + try { + Write-Output '-----------------------------------------------' + Write-Output 'Removing Missing Setup File Dependencies of:' + Write-Output " * Database: $Database" + Write-Output " * FileID: $FileID" + Write-Output " * SiteID: $SiteID" + Write-Output " * WebID: $WebID" + Write-Output '-----------------------------------------------' + #Display site information + $site = Get-SPSite $SiteID -ErrorAction SilentlyContinue + if ($null -ne $site) { + Write-Output "Checking SPSite:" $site.Url + $web = Get-SPWeb -Identity $WebID -Site $siteID -Limit ALL + if ($null -ne $web) { + Write-Output "Checking SPWeb Object ID: $WebID" + $file = $web.GetFile([GUID]$FileID) + if ($null -ne $file.ServerRelativeUrl) { + $filelocation = "{0}{1}" -f ($site.WebApplication.Url).TrimEnd("/"), $file.ServerRelativeUrl + Write-Output "Found file location: $filelocation" + #Delete the file, the Delete() method bypasses the recycle bin + if ($PSCmdlet.ShouldProcess($filelocation, "Delete setup file $FileID")) { + $file.Delete() + } + $web.dispose() + $site.dispose() + } + else { + Write-Output "SetupFileID $FileID does not exist.`nPlease check this SetupFileID" + } + } + else { + Write-Output "WebID $WebID does not exist.`nPlease check this WebID" + } + } + else { + Write-Output "SiteID $SiteID does not exist.`nPlease check this siteID" + } + } + catch { + return $_ + } +} diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingWebPart.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingWebPart.ps1 new file mode 100644 index 0000000..a7bb450 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingWebPart.ps1 @@ -0,0 +1,97 @@ +function Remove-SPSMissingWebPart { + [CmdletBinding(SupportsShouldProcess = $true)] + param + ( + [Parameter()] + [System.String] + $Database, + + [Parameter()] + [System.String] + $WebPartID, + + [Parameter()] + [System.String] + $StorageKey, + + [Parameter()] + [System.String] + $SiteID, + + [Parameter()] + [System.String] + $WebID, + + [Parameter()] + [System.String] + $DirName, + + [Parameter()] + [System.String] + $LeafName + ) + + try { + Write-Output '-----------------------------------------------' + Write-Output 'Removing Missing WebPart Dependencies of:' + Write-Output " * Database: $Database" + Write-Output " * WebPartID (class): $WebPartID" + Write-Output " * StorageKey: $StorageKey" + Write-Output " * SiteID: $SiteID" + Write-Output " * WebID: $WebID" + Write-Output " * Page: $DirName/$LeafName" + Write-Output '-----------------------------------------------' + + if ([string]::IsNullOrEmpty($StorageKey)) { + Write-Output 'StorageKey is empty - no specific WebPart instance to remove. Re-run the audit to collect per-page locations.' + return + } + + $site = Get-SPSite -Limit All -Identity $SiteID -ErrorAction SilentlyContinue + if ($null -ne $site) { + $isSiteReadOnly = $site.ReadOnly + $web = Get-SPWeb -Identity $WebID -Site $site -Limit ALL -ErrorAction SilentlyContinue + if ($null -ne $web) { + $webAppUrl = ($site.WebApplication.Url).TrimEnd('/') + $pageUrl = "{0}/{1}/{2}" -f $webAppUrl, $DirName, $LeafName + Write-Output "Page URL: $pageUrl" + + if ($isSiteReadOnly) { + Write-Output 'ReadOnly flag temporarily removed from site' + $site.ReadOnly = $false + } + + try { + $spWebPartManager = $web.GetLimitedWebPartManager($pageUrl, [System.Web.UI.WebControls.WebParts.PersonalizationScope]::Shared) + $webPartToDelete = $spWebPartManager.WebParts | Where-Object -FilterScript { $_.StorageKey -eq $StorageKey } + if ($null -ne $webPartToDelete) { + Write-Output "Removing WebPart with StorageKey $StorageKey from page" + if ($PSCmdlet.ShouldProcess($pageUrl, "Delete WebPart with StorageKey $StorageKey")) { + $spWebPartManager.DeleteWebPart($spWebPartManager.WebParts[$webPartToDelete.Id]) + } + } + else { + Write-Output "WebPart with StorageKey $StorageKey not found on page" + } + } + finally { + if ($isSiteReadOnly) { + Write-Output 'ReadOnly flag re-applied to site' + $site.ReadOnly = $true + } + $web.Dispose() + } + } + else { + Write-Output "WebID $WebID does not exist.`nPlease check this WebID" + } + $site.Dispose() + } + else { + Write-Output "SiteID $SiteID does not exist.`nPlease check this SiteID" + } + } + catch { + return $_ + } +} diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSOrphanedSite.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSOrphanedSite.ps1 new file mode 100644 index 0000000..cc0e9a3 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSOrphanedSite.ps1 @@ -0,0 +1,35 @@ +function Remove-SPSOrphanedSite { + [CmdletBinding(SupportsShouldProcess = $true)] + param + ( + [Parameter()] + [System.String] + $Database, + + [Parameter()] + [System.String] + $SiteID + ) + + try { + Write-Output '-----------------------------------------------' + Write-Output 'Removing Orphaned Site Dependencies of:' + Write-Output " * Database: $Database" + Write-Output " * SiteID: $SiteID" + Write-Output '-----------------------------------------------' + + $spContentDb = Get-SPContentDatabase $Database + if ($null -ne $spContentDb) { + Write-Output "Removing SPSite object $SiteID with the method ForceDeleteSite" + if ($PSCmdlet.ShouldProcess("SiteID $SiteID in database $Database", 'ForceDeleteSite')) { + $spContentDb.ForceDeleteSite($siteID, $false, $false) + } + } + else { + Write-Output "SPContentDatabse $Database does not exist.`nPlease check this Database" + } + } + catch { + return $_ + } +} diff --git a/src/Modules/SPSCleanDependencies.Common/SPSCleanDependencies.Common.psd1 b/src/Modules/SPSCleanDependencies.Common/SPSCleanDependencies.Common.psd1 new file mode 100644 index 0000000..2d301eb --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/SPSCleanDependencies.Common.psd1 @@ -0,0 +1,35 @@ +@{ + RootModule = 'SPSCleanDependencies.Common.psm1' + ModuleVersion = '1.3.0' + GUID = 'aa259ca2-c421-487d-80ec-b7d7b440c584' + Author = 'Jean-Cyril DROUHIN' + CompanyName = 'luigilink' + Copyright = '(c) Jean-Cyril DROUHIN. All rights reserved.' + Description = 'Shared functions for the SPSCleanDependencies toolkit (audit and clean Missing Server Side Dependencies in a SharePoint Server farm: missing features, web parts, setup files, assemblies, configurations and orphaned sites).' + + PowerShellVersion = '5.1' + + FunctionsToExport = @( + 'Get-SPSInstalledProductVersion' + 'Get-SPSMissingServerDependencies' + 'Remove-SPSMissingAssembly' + 'Remove-SPSMissingConfiguration' + 'Remove-SPSMissingFeature' + 'Remove-SPSMissingSetupFile' + 'Remove-SPSMissingWebPart' + 'Remove-SPSOrphanedSite' + ) + + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ + Tags = @('SharePoint', 'SharePointServer', 'MissingDependencies', 'ContentDatabase', 'Cleanup') + LicenseUri = 'https://github.com/luigilink/SPSCleanDependencies/blob/main/LICENSE' + ProjectUri = 'https://github.com/luigilink/SPSCleanDependencies' + ReleaseNotes = 'https://github.com/luigilink/SPSCleanDependencies/blob/main/RELEASE-NOTES.md' + } + } +} diff --git a/src/Modules/SPSCleanDependencies.Common/SPSCleanDependencies.Common.psm1 b/src/Modules/SPSCleanDependencies.Common/SPSCleanDependencies.Common.psm1 new file mode 100644 index 0000000..49e2318 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/SPSCleanDependencies.Common.psm1 @@ -0,0 +1,25 @@ +# ===================================================================================== +# SPSCleanDependencies.Common - module loader +# +# Dot-sources every *.ps1 in Private/ and Public/, then exports only the public +# function names (read from the Public folder). Private functions (SQL helpers) +# remain accessible inside the module but are hidden from callers. +# ===================================================================================== + +$script:ModuleRoot = $PSScriptRoot + +$private = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Private\*.ps1') -ErrorAction SilentlyContinue) +$public = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Public\*.ps1') -ErrorAction SilentlyContinue) + +foreach ($file in @($private + $public)) { + try { + . $file.FullName + } + catch { + Write-Error -Message "Failed to import function file '$($file.FullName)': $_" + } +} + +if ($public.Count -gt 0) { + Export-ModuleMember -Function $public.BaseName +} From b61cbc9956213c229ee4a651d7332224471c6985 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Thu, 9 Jul 2026 16:03:33 +0200 Subject: [PATCH 2/7] Refactor: relocate entry script to src/ and wire it to SPSCleanDependencies.Common Move SPSCleanDependencies.ps1 to src/ and import the SPSCleanDependencies.Common module through its manifest (.psd1) instead of the old flat .psm1. The script version is now sourced from the module manifest via (Get-Module SPSCleanDependencies.Common).Version, replacing the hard-coded '1.2.0' string. The import-time prelude (administrator check, High Performance power plan and SharePoint snap-in / SharePointServer module load) is relocated from the module into the entry script, running after the module import so it can call the public Get-SPSInstalledProductVersion. Module cleanup now targets the named module instead of Remove-Module *. The former scripts/ tree (flat util module and old entry script) is removed. Refs #6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Modules/SPSCleanDependencies.util.psm1 | 979 ------------------ .../SPSCleanDependencies.util.util.psd1 | 139 --- {scripts => src}/SPSCleanDependencies.ps1 | 56 +- 3 files changed, 42 insertions(+), 1132 deletions(-) delete mode 100644 scripts/Modules/SPSCleanDependencies.util.psm1 delete mode 100644 scripts/Modules/SPSCleanDependencies.util.util.psd1 rename {scripts => src}/SPSCleanDependencies.ps1 (78%) diff --git a/scripts/Modules/SPSCleanDependencies.util.psm1 b/scripts/Modules/SPSCleanDependencies.util.psm1 deleted file mode 100644 index 7c75832..0000000 --- a/scripts/Modules/SPSCleanDependencies.util.psm1 +++ /dev/null @@ -1,979 +0,0 @@ -function Get-SPSInstalledProductVersion { - [OutputType([System.Version])] - param () - - $pathToSearch = 'C:\Program Files\Common Files\microsoft shared\Web Server Extensions\*\ISAPI\Microsoft.SharePoint.dll' - $fullPath = Get-Item $pathToSearch -ErrorAction SilentlyContinue | Sort-Object { $_.Directory } -Descending | Select-Object -First 1 - if ($null -eq $fullPath) { - throw 'SharePoint path {C:\Program Files\Common Files\microsoft shared\Web Server Extensions} does not exist' - } - else { - return (Get-Command $fullPath).FileVersionInfo - } -} - -# Import-time prelude (admin check, power plan, SharePoint snap-in load). -# Skipped when $env:SPSCD_SKIP_PRELUDE is set, so the module can be imported on -# CI / non-SharePoint hosts (e.g. Pester) without elevation or SharePoint installed. -if (-not $env:SPSCD_SKIP_PRELUDE) { - # Ensure the script is running with administrator privileges - if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) { - Throw "Administrator rights are required. Please re-run this script as an Administrator." - } - # Setting power management plan to High Performance" - Start-Process -FilePath "$env:SystemRoot\system32\powercfg.exe" -ArgumentList '/s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c' -NoNewWindow - - # Load SharePoint Powershell Snapin or Import-Module - try { - $installedVersion = Get-SPSInstalledProductVersion - if ($installedVersion.ProductMajorPart -eq 15 -or $installedVersion.ProductBuildPart -le 12999) { - if ($null -eq (Get-PSSnapin -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue)) { - Add-PSSnapin Microsoft.SharePoint.PowerShell - } - } - else { - Import-Module SharePointServer -Verbose:$false -WarningAction SilentlyContinue -DisableNameChecking - } - } - catch { - # Handle errors during retrieval of Installed Product Version - $catchMessage = @" -Failed to get installed Product Version for $($env:COMPUTERNAME) -Exception: $($_.Exception.Message) -"@ - Write-Error -Message $catchMessage - } -} - -# Initialize jSON Object -New-Variable -Name jsonObject ` - -Description 'jSON object variable' ` - -Option AllScope ` - -Force -$jsonObject = [PSCustomObject]@{} - -#Initialize ArrayList variable -$tbSPmissingFeatures = New-Object -TypeName System.Collections.ArrayList -$tbSPmissingWebParts = New-Object -TypeName System.Collections.ArrayList -$tbSPmissingSetupFiles = New-Object -TypeName System.Collections.ArrayList -$tbSPmissingAssemblies = New-Object -TypeName System.Collections.ArrayList -$tbSPmissingConfigurations = New-Object -TypeName System.Collections.ArrayList -$tbSPmissingSiteDefinitions = New-Object -TypeName System.Collections.ArrayList -$tbSPmissingOrphanedSites = New-Object -TypeName System.Collections.ArrayList - -class SPMissingFeaturesInfo { - [System.String]$Database - [System.String]$Category - [System.String]$FeatureID - [System.String]$Message - [System.String]$Remedy - [System.String]$SiteID - [System.String]$Path -} -class SPMissingWebPartInfo { - [System.String]$Database - [System.String]$Category - [System.String]$WebPartID - [System.String]$Message - [System.String]$Remedy - [System.String]$ClassName - [System.String]$StorageKey - [System.String]$SiteID - [System.String]$WebID - [System.String]$ListID - [System.String]$DirName - [System.String]$LeafName -} -class SPMissingSetupFileInfo { - [System.String]$Database - [System.String]$Category - [System.String]$Message - [System.String]$Remedy - [System.String]$SetupPath - [System.String]$FileID - [System.String]$SiteID - [System.String]$WebID -} -class SPMissingAssemblyInfo { - [System.String]$Database - [System.String]$Category - [System.String]$Message - [System.String]$Remedy - [System.String]$AssemblyInfo - [System.String]$AssemblyID - [System.String]$HostID - [System.String]$HostType - [System.String]$SiteID - [System.String]$WebID -} -class SPMissingConfigurationInfo { - [System.String]$Database - [System.String]$Category - [System.String]$Message - [System.String]$Remedy - [System.String]$SiteID - [System.String]$Login -} -class SPMissingSiteDefinition { - [System.String]$Database - [System.String]$Category - [System.String]$Message - [System.String]$Remedy -} -class SPMissingOrphanedSites { - [System.String]$Database - [System.String]$Category - [System.String]$Message - [System.String]$Remedy - [System.String]$SiteID -} - -function Get-SQLMissingSetupFileInfo { - param - ( - [Parameter()] - [System.String] - $DatabaseName, - - [Parameter()] - [System.String] - $DatabaseServer, - - [Parameter()] - [System.String] - $SetupPath - ) - - class SQLMissingSetupFileInfo { - [System.String]$FileID - [System.String]$SiteID - [System.String]$DirName - [System.String]$LeafName - [System.String]$WebId - [System.String]$ListId - } - $tbSQLmissingSetupFiles = New-Object -TypeName System.Collections.ArrayList - try { - $sqlQuery = - @" -USE $($DatabaseName) -SELECT id, SiteID, DirName, LeafName, WebId, ListId -FROM AllDocs (NOLOCK) where SetupPath = '$($SetupPath)' -"@ - - $invokeSQLQueries = Invoke-Sqlcmd -Query $sqlQuery ` - -ServerInstance "$($DatabaseServer)" - - foreach ($invokeSQLQuery in $invokeSQLQueries) { - [void]$tbSQLmissingSetupFiles.Add([SQLMissingSetupFileInfo]@{ - FileID = $invokeSQLQuery.id; - SiteID = $invokeSQLQuery.SiteID; - DirName = $invokeSQLQuery.DirName; - LeafName = $invokeSQLQuery.LeafName; - WebId = $invokeSQLQuery.WebId; - ListId = $invokeSQLQuery.ListId; - }) - } - } - catch { - return $_ - } - return $tbSQLmissingSetupFiles -} -function Get-SQLMissingAssemblyInfo { - param - ( - [Parameter()] - [System.String] - $DatabaseName, - - [Parameter()] - [System.String] - $DatabaseServer, - - [Parameter()] - [System.String] - $AssemblyInfo - ) - - class SQLMissingSetupFileInfo { - [System.String]$AssemblyID - [System.String]$SiteID - [System.String]$WebId - [System.String]$HostId - [System.String]$HostType - } - $tbSQLmissingAssemblies = New-Object -TypeName System.Collections.ArrayList - try { - $sqlQuery = - @" -USE $($DatabaseName) -Select Id, SiteID, WebID, HostType, hostId -FROM EventReceivers (NOLOCK) where Assembly = '$($AssemblyInfo)' -"@ - - $invokeSQLQueries = Invoke-Sqlcmd -Query $sqlQuery ` - -ServerInstance "$($DatabaseServer)" - - foreach ($invokeSQLQuery in $invokeSQLQueries) { - [void]$tbSQLmissingAssemblies.Add([SQLMissingSetupFileInfo]@{ - AssemblyID = $invokeSQLQuery.id; - SiteID = $invokeSQLQuery.SiteID; - WebId = $invokeSQLQuery.WebId; - HostId = $invokeSQLQuery.HostId; - HostType = $invokeSQLQuery.HostType; - }) - } - } - catch { - return $_ - } - return $tbSQLmissingAssemblies -} - -function Get-SQLMissingWebPartInfo { - param - ( - [Parameter()] - [System.String] - $DatabaseName, - - [Parameter()] - [System.String] - $DatabaseServer, - - [Parameter()] - [System.String] - $ClassID - ) - - class SQLMissingWebPartInfo { - [System.String]$StorageKey - [System.String]$ClassName - [System.String]$SiteID - [System.String]$WebID - [System.String]$ListID - [System.String]$DirName - [System.String]$LeafName - } - $tbSQLmissingWebParts = New-Object -TypeName System.Collections.ArrayList - try { - $sqlQuery = - @" -USE $($DatabaseName) -SELECT wp.tp_ID, d.SiteId, d.WebId, d.ListId, d.DirName, d.LeafName, wp.tp_Class -FROM AllDocs d WITH (NOLOCK) -INNER JOIN AllWebParts wp WITH (NOLOCK) ON wp.tp_PageUrlID = d.Id -INNER JOIN AllWebs w WITH (NOLOCK) ON d.WebId = w.id -WHERE wp.tp_WebPartTypeId = '$($ClassID)' -"@ - - $invokeSQLQueries = Invoke-Sqlcmd -Query $sqlQuery ` - -ServerInstance "$($DatabaseServer)" - - foreach ($invokeSQLQuery in $invokeSQLQueries) { - [void]$tbSQLmissingWebParts.Add([SQLMissingWebPartInfo]@{ - StorageKey = $invokeSQLQuery.tp_ID; - ClassName = $invokeSQLQuery.tp_Class; - SiteID = $invokeSQLQuery.SiteId; - WebID = $invokeSQLQuery.WebId; - ListID = $invokeSQLQuery.ListId; - DirName = $invokeSQLQuery.DirName; - LeafName = $invokeSQLQuery.LeafName; - }) - } - } - catch { - return $_ - } - return $tbSQLmissingWebParts -} - -function Get-SQLMissingConfiguration { - param - ( - [Parameter()] - [System.String] - $DatabaseName, - - [Parameter()] - [System.String] - $DatabaseServer - ) - - class SQLMissingConfigurationInfo { - [System.String]$SiteID - [System.String]$Login - } - $tbSQLmissingConfigurations = New-Object -TypeName System.Collections.ArrayList - try { - $sqlQuery = - @" -USE $($DatabaseName) -SELECT [tp_SiteID],[tp_Login] FROM [UserInfo] WITH (NOLOCK) WHERE tp_IsActive = 1 AND tp_SiteAdmin = 1 AND tp_Deleted = 0 and tp_Login not LIKE 'i:%' -"@ - $invokeSQLQueries = Invoke-Sqlcmd -Query $sqlQuery ` - -ServerInstance "$($DatabaseServer)" - - foreach ($invokeSQLQuery in $invokeSQLQueries) { - [void]$tbSQLmissingConfigurations.Add([SQLMissingConfigurationInfo]@{ - SiteID = $invokeSQLQuery.tp_SiteID; - Login = $invokeSQLQuery.tp_Login; - }) - } - } - catch { - return $_ - } - return $tbSQLmissingConfigurations -} - -function Get-SPSMissingServerDependencies { - param - ( - [Parameter()] - [System.String] - $Path - ) - - Write-Output '-----------------------------------------------' - Write-Output 'Getting SharePoint Content Databases' - try { - $spContentDBs = Get-SPContentDatabase - if ($null -ne $spContentDBs) { - foreach ($spContentDB in $spContentDBs) { - Write-Output " * Testing SharePoint Content Database $($spContentDB.Name)" - $testDbContent = Test-SPContentDatabase $spContentDB -ShowLocation:$true -ExtendedCheck:$true - if ($null -ne $testDbContent) { - $missingFeatures = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'MissingFeature' } - $missingWebParts = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'MissingWebPart' } - $missingSetupFiles = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'MissingSetupFile' } - $missingAssemblies = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'MissingAssembly' } - $missingConfigurations = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'Configuration' } - $missingSiteDefinitions = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'MissingSiteDefinition' } - $missingOrphanedSites = $testDbContent | Where-Object -FilterScript { $_.Category -eq 'SiteOrphan' } - - if ($null -ne $missingFeatures) { - foreach ($missingFeature in $missingFeatures) { - $featureID = ([regex]::Matches($missingFeature.Message, '[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}')).value - [void]$tbSPmissingFeatures.Add([SPMissingFeaturesInfo]@{ - Database = "$($spContentDB.Name)"; - Category = $missingFeature.Category; - FeatureID = $featureID - Message = $missingFeature.Message; - Remedy = $missingFeature.Remedy; - SiteID = $missingFeature.Locations[0].SiteId; - Path = $missingFeature.Locations[0].Path; - }) - } - } - if ($null -ne $missingWebParts) { - foreach ($missingWebPart in $missingWebParts) { - $webPartID = ([regex]::Matches($missingWebPart.Message, '[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}')).value - $sqlMissingWebParts = Get-SQLMissingWebPartInfo -DatabaseName "$($spContentDB.Name)" ` - -DatabaseServer "$($spContentDB.Server)" ` - -ClassID "$($webPartID)" - - if ($null -ne $sqlMissingWebParts -and $sqlMissingWebParts.Count -gt 0) { - foreach ($sqlMissingWebPart in $sqlMissingWebParts) { - [void]$tbSPmissingWebParts.Add([SPMissingWebPartInfo]@{ - Database = "$($spContentDB.Name)"; - Category = $missingWebPart.Category; - WebPartID = $webPartID; - Message = $missingWebPart.Message; - Remedy = $missingWebPart.Remedy; - ClassName = $sqlMissingWebPart.ClassName; - StorageKey = $sqlMissingWebPart.StorageKey; - SiteID = $sqlMissingWebPart.SiteID; - WebID = $sqlMissingWebPart.WebID; - ListID = $sqlMissingWebPart.ListID; - DirName = $sqlMissingWebPart.DirName; - LeafName = $sqlMissingWebPart.LeafName; - }) - } - } - else { - [void]$tbSPmissingWebParts.Add([SPMissingWebPartInfo]@{ - Database = "$($spContentDB.Name)"; - Category = $missingWebPart.Category; - WebPartID = $webPartID; - Message = $missingWebPart.Message; - Remedy = $missingWebPart.Remedy; - }) - } - } - } - if ($null -ne $missingSetupFiles) { - foreach ($missingSetupFile in $missingSetupFiles) { - $setupPathInfo = ([regex]::Matches($missingSetupFile.Message, '(?<=\[)(\w+\\\w+.*?)(?=\])')).value - $sqlMissingSetupFileInfos = Get-SQLMissingSetupFileInfo -DatabaseName "$($spContentDB.Name)" ` - -DatabaseServer "$($spContentDB.Server)" ` - -SetupPath "$($setupPathInfo)" - - foreach ($sqlMissingSetupFileInfo in $sqlMissingSetupFileInfos) { - [void]$tbSPmissingSetupFiles.Add([SPMissingSetupFileInfo]@{ - Database = "$($spContentDB.Name)"; - Category = $missingSetupFile.Category; - Message = $missingSetupFile.Message; - Remedy = $missingSetupFile.Remedy; - SetupPath = $setupPathInfo; - FileID = $sqlMissingSetupFileInfo.FileID; - SiteID = $sqlMissingSetupFileInfo.SiteID; - WebID = $sqlMissingSetupFileInfo.WebID; - }) - } - } - } - if ($null -ne $missingAssemblies) { - foreach ($missingAssembly in $missingAssemblies) { - $assemblyInfo = ([regex]::Matches($missingAssembly.Message, '(?<=\[)(\w+\.\w+.*?)(?=\])')).value - $sqlMissingAssemblies = Get-SQLMissingAssemblyInfo -DatabaseName "$($spContentDB.Name)" ` - -DatabaseServer "$($spContentDB.Server)" ` - -AssemblyInfo "$($assemblyInfo)" - - foreach ($sqlMissingAssembly in $sqlMissingAssemblies) { - [void]$tbSPmissingAssemblies.Add([SPMissingAssemblyInfo]@{ - Database = "$($spContentDB.Name)"; - Category = $missingAssembly.Category; - Message = $missingAssembly.Message; - Remedy = $missingAssembly.Remedy; - AssemblyInfo = $assemblyInfo; - AssemblyID = $sqlMissingAssembly.AssemblyID; - SiteID = $sqlMissingAssembly.SiteID; - WebID = $sqlMissingAssembly.WebID; - HostID = $sqlMissingAssembly.HostID; - HostType = $sqlMissingAssembly.HostType; - }) - } - } - } - if ($null -ne $missingConfigurations) { - foreach ($missingConfiguration in $missingConfigurations) { - if (([regex]::Matches($missingConfiguration.Message, '^(.*?(\bclaims\b)[^$]*)$')).success) { - $sqlMissingConfigurations = Get-SQLMissingConfiguration -DatabaseName "$($spContentDB.Name)" ` - -DatabaseServer "$($spContentDB.Server)" - } - foreach ($sqlMissingConfiguration in $sqlMissingConfigurations) { - [void]$tbSPmissingConfigurations.Add([SPMissingConfigurationInfo]@{ - Database = "$($spContentDB.Name)"; - Category = $missingConfiguration.Category; - Message = $missingConfiguration.Message; - Remedy = $missingConfiguration.Remedy; - SiteID = $sqlMissingConfiguration.SiteID; - Login = $sqlMissingConfiguration.Login; - }) - } - } - } - if ($null -ne $missingSiteDefinitions) { - foreach ($missingSiteDefinition in $missingSiteDefinitions) { - [void]$tbSPmissingSiteDefinitions.Add([SPMissingSiteDefinition]@{ - Database = "$($spContentDB.Name)"; - Category = $missingSiteDefinition.Category; - Message = $missingSiteDefinition.Message; - Remedy = $missingSiteDefinition.Remedy; - }) - } - } - if ($null -ne $missingOrphanedSites) { - foreach ($missingOrphanedSite in $missingOrphanedSites) { - $siteID = ([regex]::Matches($missingOrphanedSite.Message, '[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}')).value - [void]$tbSPmissingOrphanedSites.Add([SPMissingOrphanedSites]@{ - Database = "$($spContentDB.Name)"; - Category = $missingOrphanedSite.Category; - Message = $missingOrphanedSite.Message; - Remedy = $missingOrphanedSite.Remedy; - SiteID = $siteID; - }) - } - } - } - } - Write-Output 'Adding each list object in PsCustomObject jsonObject:' - if ($null -ne $tbSPmissingFeatures) { - Write-Output '* Adding MissingFeature object' - $jsonObject | Add-Member -MemberType NoteProperty ` - -Name MissingFeature ` - -Value $tbSPmissingFeatures - } - if ($null -ne $tbSPmissingWebParts) { - Write-Output '* Adding MissingWebPart object' - $jsonObject | Add-Member -MemberType NoteProperty ` - -Name MissingWebPart ` - -Value $tbSPmissingWebParts - } - if ($null -ne $tbSPmissingSetupFiles) { - Write-Output '* Adding MissingSetupFile object' - $jsonObject | Add-Member -MemberType NoteProperty ` - -Name MissingSetupFile ` - -Value $tbSPmissingSetupFiles - } - if ($null -ne $tbSPmissingAssemblies) { - Write-Output '* Adding MissingAssembly object' - $jsonObject | Add-Member -MemberType NoteProperty ` - -Name MissingAssembly ` - -Value $tbSPmissingAssemblies - } - if ($null -ne $tbSPmissingConfigurations) { - Write-Output '* Adding MissingConfiguration object' - $jsonObject | Add-Member -MemberType NoteProperty ` - -Name Configuration ` - -Value $tbSPmissingConfigurations - } - if ($null -ne $tbSPmissingSiteDefinitions) { - Write-Output '* Adding MissingSiteDefinitions object' - $jsonObject | Add-Member -MemberType NoteProperty ` - -Name MissingSiteDefinition ` - -Value $tbSPmissingSiteDefinitions - } - if ($null -ne $tbSPmissingOrphanedSites) { - Write-Output '* Adding MissingSiteOrphans object' - $jsonObject | Add-Member -MemberType NoteProperty ` - -Name SiteOrphan ` - -Value $tbSPmissingOrphanedSites - } - $jsonObject | ConvertTo-Json | Set-Content -Path $Path -Force - } - } - catch { - return $_ - } -} - -function Remove-SPSMissingFeature { - [CmdletBinding(SupportsShouldProcess = $true)] - param - ( - [Parameter()] - [System.String] - $Database, - - [Parameter()] - [System.String] - $FeatureID, - - [Parameter()] - [System.String] - $SiteID - ) - - try { - Write-Output '-----------------------------------------------' - Write-Output 'Removing Missing Feature Dependencies of:' - Write-Output " * Database: $Database" - Write-Output " * FeatureID: $FeatureID" - Write-Output " * SiteID: $SiteID" - Write-Output '-----------------------------------------------' - #Display site information - $site = Get-SPSite $SiteID -ErrorAction SilentlyContinue - if ($null -ne $site) { - Write-Output "Checking SPSite:" $site.Url - #Remove the feature from all subsites - ForEach ($web in $Site.AllWebs) { - if ($web.Features[$featureID]) { - Write-Output "`nFound Feature $featureID in web:"$Web.Url"`nRemoving feature" - if ($PSCmdlet.ShouldProcess($Web.Url, "Remove feature $featureID")) { - $web.Features.Remove($featureID, $true) - } - } - else { - Write-Output "`nDid not find feature $featureID in web:" $Web.Url - } - } - #Remove the feature from the site collection - if ($Site.Features[$featureID]) { - Write-Output "`nFound feature $featureID in site:"$site.Url"`nRemoving Feature" - if ($PSCmdlet.ShouldProcess($site.Url, "Remove feature $featureID")) { - $site.Features.Remove($featureID, $true) - } - } - else { - Write-Output "Did not find feature $featureID in site:" $site.Url - } - } - else { - Write-Output "SiteID $SiteID does not exist.`nPlease check this siteID" - } - } - catch { - return $_ - } -} - -function Remove-SPSMissingSetupFile { - [CmdletBinding(SupportsShouldProcess = $true)] - param - ( - [Parameter()] - [System.String] - $Database, - - [Parameter()] - [System.String] - $FileID, - - [Parameter()] - [System.String] - $SiteID, - - [Parameter()] - [System.String] - $WebID - ) - - try { - Write-Output '-----------------------------------------------' - Write-Output 'Removing Missing Setup File Dependencies of:' - Write-Output " * Database: $Database" - Write-Output " * FileID: $FileID" - Write-Output " * SiteID: $SiteID" - Write-Output " * WebID: $WebID" - Write-Output '-----------------------------------------------' - #Display site information - $site = Get-SPSite $SiteID -ErrorAction SilentlyContinue - if ($null -ne $site) { - Write-Output "Checking SPSite:" $site.Url - $web = Get-SPWeb -Identity $WebID -Site $siteID -Limit ALL - if ($null -ne $web) { - Write-Output "Checking SPWeb Object ID: $WebID" - $file = $web.GetFile([GUID]$FileID) - if ($null -ne $file.ServerRelativeUrl) { - $filelocation = "{0}{1}" -f ($site.WebApplication.Url).TrimEnd("/"), $file.ServerRelativeUrl - Write-Output "Found file location: $filelocation" - #Delete the file, the Delete() method bypasses the recycle bin - if ($PSCmdlet.ShouldProcess($filelocation, "Delete setup file $FileID")) { - $file.Delete() - } - $web.dispose() - $site.dispose() - } - else { - Write-Output "SetupFileID $FileID does not exist.`nPlease check this SetupFileID" - } - } - else { - Write-Output "WebID $WebID does not exist.`nPlease check this WebID" - } - } - else { - Write-Output "SiteID $SiteID does not exist.`nPlease check this siteID" - } - } - catch { - return $_ - } -} - -function Remove-SPSMissingWebPart { - [CmdletBinding(SupportsShouldProcess = $true)] - param - ( - [Parameter()] - [System.String] - $Database, - - [Parameter()] - [System.String] - $WebPartID, - - [Parameter()] - [System.String] - $StorageKey, - - [Parameter()] - [System.String] - $SiteID, - - [Parameter()] - [System.String] - $WebID, - - [Parameter()] - [System.String] - $DirName, - - [Parameter()] - [System.String] - $LeafName - ) - - try { - Write-Output '-----------------------------------------------' - Write-Output 'Removing Missing WebPart Dependencies of:' - Write-Output " * Database: $Database" - Write-Output " * WebPartID (class): $WebPartID" - Write-Output " * StorageKey: $StorageKey" - Write-Output " * SiteID: $SiteID" - Write-Output " * WebID: $WebID" - Write-Output " * Page: $DirName/$LeafName" - Write-Output '-----------------------------------------------' - - if ([string]::IsNullOrEmpty($StorageKey)) { - Write-Output 'StorageKey is empty - no specific WebPart instance to remove. Re-run the audit to collect per-page locations.' - return - } - - $site = Get-SPSite -Limit All -Identity $SiteID -ErrorAction SilentlyContinue - if ($null -ne $site) { - $isSiteReadOnly = $site.ReadOnly - $web = Get-SPWeb -Identity $WebID -Site $site -Limit ALL -ErrorAction SilentlyContinue - if ($null -ne $web) { - $webAppUrl = ($site.WebApplication.Url).TrimEnd('/') - $pageUrl = "{0}/{1}/{2}" -f $webAppUrl, $DirName, $LeafName - Write-Output "Page URL: $pageUrl" - - if ($isSiteReadOnly) { - Write-Output 'ReadOnly flag temporarily removed from site' - $site.ReadOnly = $false - } - - try { - $spWebPartManager = $web.GetLimitedWebPartManager($pageUrl, [System.Web.UI.WebControls.WebParts.PersonalizationScope]::Shared) - $webPartToDelete = $spWebPartManager.WebParts | Where-Object -FilterScript { $_.StorageKey -eq $StorageKey } - if ($null -ne $webPartToDelete) { - Write-Output "Removing WebPart with StorageKey $StorageKey from page" - if ($PSCmdlet.ShouldProcess($pageUrl, "Delete WebPart with StorageKey $StorageKey")) { - $spWebPartManager.DeleteWebPart($spWebPartManager.WebParts[$webPartToDelete.Id]) - } - } - else { - Write-Output "WebPart with StorageKey $StorageKey not found on page" - } - } - finally { - if ($isSiteReadOnly) { - Write-Output 'ReadOnly flag re-applied to site' - $site.ReadOnly = $true - } - $web.Dispose() - } - } - else { - Write-Output "WebID $WebID does not exist.`nPlease check this WebID" - } - $site.Dispose() - } - else { - Write-Output "SiteID $SiteID does not exist.`nPlease check this SiteID" - } - } - catch { - return $_ - } -} - -function Remove-SPSMissingAssembly { - [CmdletBinding(SupportsShouldProcess = $true)] - param - ( - [Parameter()] - [System.String] - $Database, - - [Parameter()] - [System.String] - $AssemblyID, - - [Parameter()] - [System.String] - $SiteID, - - [Parameter()] - [System.String] - $WebID, - - [Parameter()] - [System.String] - $HostType, - - [Parameter()] - [System.String] - $HostID - ) - - try { - Write-Output '-----------------------------------------------' - Write-Output 'Removing Missing Setup File Dependencies of:' - Write-Output " * Database: $Database" - Write-Output " * AssemblyID: $AssemblyID" - Write-Output " * SiteID: $SiteID" - Write-Output " * WebID: $WebID" - Write-Output " * HostType: $HostType" - Write-Output " * HostID: $HostID" - Write-Output '-----------------------------------------------' - - switch ($HostType) { - '0' { - Write-Output ' * HostTypeValue: 0 => SPSite' - $site = Get-SPSite -limit all -Identity $siteID - if ($null -ne $site) { - $AssemblyToDelete = $site.EventReceivers | Where-Object -FilterScript { - $_.id -eq $AssemblyID - } - if ($null -ne $AssemblyToDelete) { - Write-Output "Removing AssemblyID $AssemblyID from SPSite object." - if ($PSCmdlet.ShouldProcess("SPSite $SiteID", "Delete AssemblyID $AssemblyID")) { - $AssemblyToDelete.delete() - } - } - else { - Write-Output "AssemblyID $AssemblyID does not exist in SPSite object." - } - $site.dispose() - } - else { - Write-Output "SiteID $SiteID does not exist.`nPlease check this siteID" - } - } - '1' { - Write-Output ' * HostTypeValue: 1 => SPWeb' - $web = Get-SPWeb -Identity $webID -Site $siteID -Limit ALL - if ($null -ne $web) { - $AssemblyToDelete = $web.EventReceivers | Where-Object -FilterScript { - $_.id -eq $AssemblyID - } - if ($null -ne $AssemblyToDelete) { - Write-Output "Removing AssemblyID $AssemblyID from SPWeb object." - if ($PSCmdlet.ShouldProcess("SPWeb $WebID", "Delete AssemblyID $AssemblyID")) { - $AssemblyToDelete.delete() - } - } - else { - Write-Output "AssemblyID $AssemblyID does not exist in SPWeb object." - } - $web.dispose() - } - else { - Write-Output "WebID $WebID does not exist.`nPlease check this WebID" - } - } - '2' { - Write-Output ' * HostTypeValue: 2 => SPList' - $web = Get-SPWeb -Identity $webID -Site $siteID -Limit ALL - if ($null -ne $web) { - $list = $web.lists | Where-Object -FilterScript { $_.id -eq $hostID } - if ($null -ne $list) { - $AssemblyToDelete = $list.EventReceivers | Where-Object -FilterScript { - $_.id -eq $AssemblyID - } - if ($null -ne $AssemblyToDelete) { - Write-Output "Removing AssemblyID $AssemblyID from SPList object." - if ($PSCmdlet.ShouldProcess("SPList $hostID", "Delete AssemblyID $AssemblyID")) { - $AssemblyToDelete.delete() - } - } - else { - Write-Output "AssemblyID $AssemblyID does not exist in SPList object." - } - $web.dispose() - } - else { - Write-Output "List with host $hostID does not exist.`nPlease check this hostID" - } - } - else { - Write-Output "WebID $WebID does not exist.`nPlease check this WebID" - } - } - } - } - catch { - return $_ - } -} - -function Remove-SPSMissingConfiguration { - [CmdletBinding(SupportsShouldProcess = $true)] - param - ( - [Parameter()] - [System.String] - $Database, - - [Parameter()] - [System.String] - $SiteID, - - [Parameter()] - [System.String] - $Login - ) - - try { - Write-Output '-----------------------------------------------' - Write-Output 'Removing Missing Configuration Dependencies of:' - Write-Output " * Database: $Database" - Write-Output " * SiteID: $SiteID" - Write-Output " * Login: $Login" - Write-Output '-----------------------------------------------' - - $site = Get-SPSite -limit all -Identity $siteID - if ($null -ne $site) { - Write-Output "Checking SPSite:" $site.Url - $webs = Get-SPWeb -Site $siteID -Limit ALL - if ($null -ne $webs) { - foreach ($web in $webs) { - if ($web.SiteAdministrators.UserLogin -contains $Login) { - Write-Output "$Login exists in SiteAdministrators" - Write-Output "Removing login: $Login" - Write-Output "of SiteAdministrator Property of SPWeb:" - Write-Output "$($web.Url)" - if ($PSCmdlet.ShouldProcess($web.Url, "Remove SiteAdministrator $Login")) { - $web.SiteAdministrators.Remove($Login) - } - } - else { - Write-Output "$Login does not exist in SiteAdministrators Property" - } - $web.Dispose() - } - $site.Dispose() - } - else { - Write-Output "No SPWeb Object for this SiteID $SiteID" - } - } - else { - Write-Output "SiteID $SiteID does not exist.`nPlease check this siteID" - } - } - catch { - return $_ - } -} - -function Remove-SPSOrphanedSite { - [CmdletBinding(SupportsShouldProcess = $true)] - param - ( - [Parameter()] - [System.String] - $Database, - - [Parameter()] - [System.String] - $SiteID - ) - - try { - Write-Output '-----------------------------------------------' - Write-Output 'Removing Orphaned Site Dependencies of:' - Write-Output " * Database: $Database" - Write-Output " * SiteID: $SiteID" - Write-Output '-----------------------------------------------' - - $spContentDb = Get-SPContentDatabase $Database - if ($null -ne $spContentDb) { - Write-Output "Removing SPSite object $SiteID with the method ForceDeleteSite" - if ($PSCmdlet.ShouldProcess("SiteID $SiteID in database $Database", 'ForceDeleteSite')) { - $spContentDb.ForceDeleteSite($siteID, $false, $false) - } - } - else { - Write-Output "SPContentDatabse $Database does not exist.`nPlease check this Database" - } - } - catch { - return $_ - } -} diff --git a/scripts/Modules/SPSCleanDependencies.util.util.psd1 b/scripts/Modules/SPSCleanDependencies.util.util.psd1 deleted file mode 100644 index 732e0c2..0000000 --- a/scripts/Modules/SPSCleanDependencies.util.util.psd1 +++ /dev/null @@ -1,139 +0,0 @@ -# -# Module manifest for module 'SPSCleanDependencies.util' -# -# Generated by: luigilink (Jean-Cyril DROUHIN) -# -# Generated on: 04/04/2025 -# - -@{ - - # Script module or binary module file associated with this manifest. - RootModule = 'SPSCleanDependencies.util.psm1' - - # Version number of this module. - ModuleVersion = '1.0.0' - - # ID used to uniquely identify this module - GUID = 'aa259ca2-c421-487d-80ec-b7d7b440c584' - - # Author of this module - Author = 'luigilink' - - # Company or vendor of this module - CompanyName = 'SPSCleanDependencies' - - # Copyright statement for this module - Copyright = 'Copyright the SPSCleanDependencies Community contributors. All rights reserved.' - - # Description of the functionality provided by this module - Description = 'Functions Util used by SPSCleanDependencies.' - - # Minimum version of the PowerShell engine required by this module - # PowerShellVersion = '' - - # Name of the PowerShell host required by this module - # PowerShellHostName = '' - - # Minimum version of the PowerShell host required by this module - # PowerShellHostVersion = '' - - # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. - # DotNetFrameworkVersion = '' - - # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. - # ClrVersion = '' - - # Processor architecture (None, X86, Amd64) required by this module - # ProcessorArchitecture = '' - - # Modules that must be imported into the global environment prior to importing this module - # RequiredModules = @() - - # Assemblies that must be loaded prior to importing this module - # RequiredAssemblies = @() - - # Script files (.ps1) that are run in the caller's environment prior to importing this module. - # ScriptsToProcess = @() - - # Type files (.ps1xml) to be loaded when importing this module - # TypesToProcess = @() - - # Format files (.ps1xml) to be loaded when importing this module - # FormatsToProcess = @() - - # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess - # NestedModules = @() - - # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. - FunctionsToExport = @( - 'Get-SQLMissingSetupFileInfo', - 'Get-SQLMissingAssemblyInfo', - 'Get-SQLMissingConfiguration', - 'Get-SPSInstalledProductVersion', - 'Get-SPSMissingServerDependencies', - 'Remove-SPSMissingFeature', - 'Remove-SPSMissingSetupFile', - 'Remove-SPSMissingAssembly', - 'Remove-SPSMissingConfiguration', - 'Remove-SPSOrphanedSite' - ) - - # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. - CmdletsToExport = @() - - # Variables to export from this module - VariablesToExport = '*' - - # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. - AliasesToExport = @() - - # DSC resources to export from this module - # DscResourcesToExport = @() - - # List of all modules packaged with this module - # ModuleList = @() - - # List of all files packaged with this module - # FileList = @() - - # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. - PrivateData = @{ - - PSData = @{ - - # Tags applied to this module. These help with module discovery in online galleries. - # Tags = @() - - # A URL to the license for this module. - # LicenseUri = '' - - # A URL to the main website for this project. - # ProjectUri = '' - - # A URL to an icon representing this module. - # IconUri = '' - - # ReleaseNotes of this module - # ReleaseNotes = '' - - # Prerelease string of this module - # Prerelease = '' - - # Flag to indicate whether the module requires explicit user acceptance for install/update/save - # RequireLicenseAcceptance = $false - - # External dependent modules of this module - # ExternalModuleDependencies = @() - - } # End of PSData hashtable - - } # End of PrivateData hashtable - - # HelpInfo URI of this module - # HelpInfoURI = '' - - # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. - # DefaultCommandPrefix = '' - -} diff --git a/scripts/SPSCleanDependencies.ps1 b/src/SPSCleanDependencies.ps1 similarity index 78% rename from scripts/SPSCleanDependencies.ps1 rename to src/SPSCleanDependencies.ps1 index f76a690..cc7818e 100644 --- a/scripts/SPSCleanDependencies.ps1 +++ b/src/SPSCleanDependencies.ps1 @@ -1,9 +1,11 @@ -<# +<# .SYNOPSIS SPSCleanDependencies script for SharePoint Server .DESCRIPTION - SPSCleanDependencies is a PowerShell script tool to clean Missing Server Dependencies in your SharePoint Farm + SPSCleanDependencies is a PowerShell script tool to clean Missing Server Dependencies in your SharePoint Farm. + Shared logic lives in the SPSCleanDependencies.Common module (src/Modules/SPSCleanDependencies.Common); + the script version is sourced from that module's manifest (ModuleVersion). .PARAMETER FileName Specify the name of the file to be used for the script. @@ -22,8 +24,8 @@ .NOTES FileName: SPSCleanDependencies.ps1 Author: luigilink (Jean-Cyril DROUHIN) - Date: June 11, 2026 - Version: 1.2.0 + Date: July 9, 2026 + Version: Defined by the SPSCleanDependencies.Common module manifest (ModuleVersion) .LINK https://spjc.fr/ @@ -48,23 +50,49 @@ Clear-Host # Set the window title $Host.UI.RawUI.WindowTitle = "SPSCleanDependencies script running on $env:COMPUTERNAME" -# Define the path to the helper module -$scriptRootPath = Split-Path -Parent $MyInvocation.MyCommand.Definition -$script:HelperModulePath = Join-Path -Path $scriptRootPath -ChildPath 'Modules' - -# Import the helper module +# Import the helper module (SPSCleanDependencies.Common) +$script:HelperModulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules' try { - Import-Module -Name (Join-Path -Path $script:HelperModulePath -ChildPath 'SPSCleanDependencies.util.psm1') -Force -DisableNameChecking + Import-Module -Name (Join-Path -Path $script:HelperModulePath -ChildPath 'SPSCleanDependencies.Common\SPSCleanDependencies.Common.psd1') -Force -ErrorAction Stop } catch { # Handle errors during Import of helper module Write-Error -Message @" -Failed to import helper module from path: $($script:HelperModulePath) +Failed to import SPSCleanDependencies.Common module from path: $($script:HelperModulePath) Exception: $_ "@ Exit } +# Ensure the script is running with administrator privileges +if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) { + Throw "Administrator rights are required. Please re-run this script as an Administrator." +} + +# Setting power management plan to High Performance +Start-Process -FilePath "$env:SystemRoot\system32\powercfg.exe" -ArgumentList '/s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c' -NoNewWindow + +# Load SharePoint PowerShell Snap-in or Import-Module +try { + $installedVersion = Get-SPSInstalledProductVersion + if ($installedVersion.ProductMajorPart -eq 15 -or $installedVersion.ProductBuildPart -le 12999) { + if ($null -eq (Get-PSSnapin -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue)) { + Add-PSSnapin Microsoft.SharePoint.PowerShell + } + } + else { + Import-Module SharePointServer -Verbose:$false -WarningAction SilentlyContinue -DisableNameChecking + } +} +catch { + # Handle errors during retrieval of Installed Product Version + Write-Error -Message @" +Failed to get installed Product Version for $($env:COMPUTERNAME) +Exception: $($_.Exception.Message) +"@ + Exit +} + # Import SQL Server PowerShell module try { Import-Module -Name SqlServer -ErrorAction Stop @@ -80,9 +108,9 @@ Exception: $_ } # Define variable -$SPSCleanDependenciesVersion = '1.2.0' +$SPSCleanDependenciesVersion = (Get-Module -Name 'SPSCleanDependencies.Common').Version.ToString() $currentUser = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name -$scriptRootPath = Split-Path -parent $MyInvocation.MyCommand.Definition +$scriptRootPath = $PSScriptRoot $pathLogsFolder = Join-Path -Path $scriptRootPath -ChildPath 'Logs' -ErrorAction SilentlyContinue $pathResultsFolder = Join-Path -Path $scriptRootPath -ChildPath 'Results' -ErrorAction SilentlyContinue @@ -206,6 +234,6 @@ Write-Output "| Ended on - $DateEnded" Write-Output '-----------------------------------------------' Stop-Transcript Remove-Variable * -ErrorAction SilentlyContinue -Remove-Module * -ErrorAction SilentlyContinue +Remove-Module -Name 'SPSCleanDependencies.Common' -ErrorAction SilentlyContinue $error.Clear() Exit From 295c184b9757c2908531677e842ab0a765670b7c Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Thu, 9 Jul 2026 16:06:43 +0200 Subject: [PATCH 3/7] test: update Pester suite for the SPSCleanDependencies.Common layout Point the entry-script tests at src/SPSCleanDependencies.ps1 and assert it now imports SPSCleanDependencies.Common via its manifest and sources the version from (Get-Module SPSCleanDependencies.Common).Version. Rename the module test file to SPSCleanDependencies.Common.Tests.ps1 and adapt it to the new structure: import via the .psd1 manifest, resolve the internal Get-SQLMissing* helpers through InModuleScope (they are now Private and no longer exported), assert FunctionsToExport matches the exported set, and match the SPMissing* class definitions against Public/Get-SPSMissingServerDependencies.ps1. Refs #6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ... => SPSCleanDependencies.Common.Tests.ps1} | 90 ++++++++++++------- tests/SPSCleanDependencies.Tests.ps1 | 14 +-- 2 files changed, 69 insertions(+), 35 deletions(-) rename tests/Modules/{SPSCleanDependencies.util.Tests.ps1 => SPSCleanDependencies.Common.Tests.ps1} (61%) diff --git a/tests/Modules/SPSCleanDependencies.util.Tests.ps1 b/tests/Modules/SPSCleanDependencies.Common.Tests.ps1 similarity index 61% rename from tests/Modules/SPSCleanDependencies.util.Tests.ps1 rename to tests/Modules/SPSCleanDependencies.Common.Tests.ps1 index c781ddf..a66f1db 100644 --- a/tests/Modules/SPSCleanDependencies.util.Tests.ps1 +++ b/tests/Modules/SPSCleanDependencies.Common.Tests.ps1 @@ -1,14 +1,13 @@ -# Pester tests for SPSCleanDependencies.util.psm1 -# Resolve repo root - works on both local and CI/CD +# Pester tests for the SPSCleanDependencies.Common module. +# Resolve repo root - works on both local and CI/CD. BeforeAll { $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent - $script:modulePath = Join-Path -Path $repoRoot -ChildPath 'scripts/Modules/SPSCleanDependencies.util.psm1' - - # Skip the module's import-time prelude (admin check, powercfg, SharePoint snap-in load), - # which only makes sense on a real SharePoint farm. - $script:previousSkipPrelude = $env:SPSCD_SKIP_PRELUDE - $env:SPSCD_SKIP_PRELUDE = '1' + $script:moduleRoot = Join-Path -Path $repoRoot -ChildPath 'src/Modules/SPSCleanDependencies.Common' + $script:moduleManifest = Join-Path -Path $script:moduleRoot -ChildPath 'SPSCleanDependencies.Common.psd1' + $script:moduleFile = Join-Path -Path $script:moduleRoot -ChildPath 'SPSCleanDependencies.Common.psm1' + $script:moduleName = 'SPSCleanDependencies.Common' + $script:dependenciesFunctionFile = Join-Path -Path $script:moduleRoot -ChildPath 'Public/Get-SPSMissingServerDependencies.ps1' # Stub SharePoint cmdlets so the module can be imported on non-Windows / no-SharePoint hosts. # Real behaviour is exercised on a SharePoint farm; these tests only validate shape & contracts. @@ -24,38 +23,46 @@ BeforeAll { } } - # Surface real import errors instead of silently hiding them (which previously - # produced a cascade of misleading "$null or empty" failures). - Import-Module -Name $script:modulePath -Force -DisableNameChecking + # The module is import-safe by design (the admin/powercfg/snap-in prelude now lives in the + # entry script, not the module). Surface real import errors instead of hiding them. + Import-Module -Name $script:moduleManifest -Force -DisableNameChecking } AfterAll { - Remove-Module -Name 'SPSCleanDependencies.util' -Force -ErrorAction SilentlyContinue - $env:SPSCD_SKIP_PRELUDE = $script:previousSkipPrelude + Remove-Module -Name 'SPSCleanDependencies.Common' -Force -ErrorAction SilentlyContinue } -Describe 'SPSCleanDependencies.util.psm1 Module' { +Describe 'SPSCleanDependencies.Common Module' { + + It 'module manifest exists' { + $script:moduleManifest | Should -Exist + } - It 'module file exists' { - $script:modulePath | Should -Exist + It 'loader module file exists' { + $script:moduleFile | Should -Exist } - It 'has valid PowerShell syntax' { + It 'loader has valid PowerShell syntax' { $parseErrors = $null $tokens = $null $null = [System.Management.Automation.Language.Parser]::ParseInput( - (Get-Content -Path $script:modulePath -Raw), [ref]$tokens, [ref]$parseErrors) + (Get-Content -Path $script:moduleFile -Raw), [ref]$tokens, [ref]$parseErrors) $parseErrors | Should -BeNullOrEmpty } + It 'manifest declares a ModuleVersion' { + (Test-ModuleManifest -Path $script:moduleManifest).Version | Should -Not -BeNullOrEmpty + } + It 'module loads successfully' { - Get-Module -Name 'SPSCleanDependencies.util' | Should -Not -BeNullOrEmpty + Get-Module -Name $script:moduleName | Should -Not -BeNullOrEmpty } } -Describe 'SPSCleanDependencies.util.psm1 Public Functions' { +Describe 'SPSCleanDependencies.Common Public Functions' { $publicFunctions = @( + 'Get-SPSInstalledProductVersion', 'Get-SPSMissingServerDependencies', 'Remove-SPSMissingFeature', 'Remove-SPSMissingSetupFile', @@ -68,9 +75,24 @@ Describe 'SPSCleanDependencies.util.psm1 Public Functions' { It 'exports <_>' -ForEach $publicFunctions { Get-Command -Name $_ -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty } + + It 'manifest FunctionsToExport matches the exported set' { + $expectedExports = @( + 'Get-SPSInstalledProductVersion', + 'Get-SPSMissingServerDependencies', + 'Remove-SPSMissingFeature', + 'Remove-SPSMissingSetupFile', + 'Remove-SPSMissingAssembly', + 'Remove-SPSMissingConfiguration', + 'Remove-SPSMissingWebPart', + 'Remove-SPSOrphanedSite' + ) | Sort-Object + $exported = (Get-Module -Name $script:moduleName).ExportedFunctions.Keys | Sort-Object + $exported | Should -Be $expectedExports + } } -Describe 'SPSCleanDependencies.util.psm1 SQL Helper Functions' { +Describe 'SPSCleanDependencies.Common Private SQL Helpers' { $sqlHelpers = @( 'Get-SQLMissingSetupFileInfo', @@ -79,12 +101,20 @@ Describe 'SPSCleanDependencies.util.psm1 SQL Helper Functions' { 'Get-SQLMissingConfiguration' ) - It 'defines helper <_>' -ForEach $sqlHelpers { - Get-Command -Name $_ -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + It 'defines internal helper <_> (module scope)' -ForEach $sqlHelpers { + $helper = $_ + InModuleScope -ModuleName 'SPSCleanDependencies.Common' -Parameters @{ helper = $helper } { + param($helper) + Get-Command -Name $helper -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + } + } + + It 'does not export the SQL helpers to callers <_>' -ForEach $sqlHelpers { + (Get-Module -Name $script:moduleName).ExportedFunctions.Keys | Should -Not -Contain $_ } } -Describe 'SPSCleanDependencies.util.psm1 Function Parameter Contracts' { +Describe 'SPSCleanDependencies.Common Function Parameter Contracts' { It 'Get-SPSMissingServerDependencies has a Path parameter' { (Get-Command Get-SPSMissingServerDependencies).Parameters.Keys | Should -Contain 'Path' @@ -154,7 +184,7 @@ Describe 'Remove-SPSMissingWebPart Safety Net' { } } -Describe 'SPSCleanDependencies.util.psm1 ShouldProcess Support' { +Describe 'SPSCleanDependencies.Common ShouldProcess Support' { $stateChangingFunctions = @( 'Remove-SPSMissingFeature', @@ -173,10 +203,10 @@ Describe 'SPSCleanDependencies.util.psm1 ShouldProcess Support' { } } -Describe 'SPSCleanDependencies.util.psm1 Class Definitions' { +Describe 'SPSCleanDependencies.Common Class Definitions' { BeforeAll { - $script:moduleContent = Get-Content -Path $script:modulePath -Raw + $script:functionContent = Get-Content -Path $script:dependenciesFunctionFile -Raw } $expectedClasses = @( @@ -189,11 +219,11 @@ Describe 'SPSCleanDependencies.util.psm1 Class Definitions' { 'SPMissingOrphanedSites' ) - It 'defines class <_>' -ForEach $expectedClasses { - $script:moduleContent | Should -Match "class\s+$_\b" + It 'defines class <_> in Get-SPSMissingServerDependencies.ps1' -ForEach $expectedClasses { + $script:functionContent | Should -Match "class\s+$_\b" } It 'SPMissingWebPartInfo class carries per-page location fields' { - $script:moduleContent | Should -Match 'class\s+SPMissingWebPartInfo[\s\S]*\$StorageKey[\s\S]*\$DirName[\s\S]*\$LeafName' + $script:functionContent | Should -Match 'class\s+SPMissingWebPartInfo[\s\S]*\$StorageKey[\s\S]*\$DirName[\s\S]*\$LeafName' } } diff --git a/tests/SPSCleanDependencies.Tests.ps1 b/tests/SPSCleanDependencies.Tests.ps1 index f41bd22..07ed494 100644 --- a/tests/SPSCleanDependencies.Tests.ps1 +++ b/tests/SPSCleanDependencies.Tests.ps1 @@ -3,7 +3,7 @@ BeforeAll { $repoRoot = Split-Path -Path $PSScriptRoot -Parent - $script:scriptPath = Join-Path -Path $repoRoot -ChildPath 'scripts/SPSCleanDependencies.ps1' + $script:scriptPath = Join-Path -Path $repoRoot -ChildPath 'src/SPSCleanDependencies.ps1' $script:scriptContent = Get-Content -Path $script:scriptPath -Raw -ErrorAction SilentlyContinue } @@ -44,8 +44,8 @@ Describe 'SPSCleanDependencies.ps1 Metadata' { $script:scriptContent | Should -Match 'Author:\s*luigilink' } - It 'Should declare a Version in NOTES' { - $script:scriptContent | Should -Match 'Version:\s*\d+\.\d+\.\d+' + It 'Should source its Version from the SPSCleanDependencies.Common manifest' { + $script:scriptContent | Should -Match 'Version:\s*Defined by the SPSCleanDependencies\.Common module manifest' } It 'Should require PowerShell 5.1 or higher' { @@ -109,8 +109,12 @@ Describe 'SPSCleanDependencies.ps1 Parameters' { Describe 'SPSCleanDependencies.ps1 Module Imports' { - It 'Should import the helper module SPSCleanDependencies.util.psm1' { - $script:scriptContent | Should -Match 'Import-Module[^\n]*SPSCleanDependencies\.util\.psm1' + It 'Should import the SPSCleanDependencies.Common module via its manifest' { + $script:scriptContent | Should -Match 'Import-Module[^\n]*SPSCleanDependencies\.Common\.psd1' + } + + It 'Should source the script version from the module manifest' { + $script:scriptContent | Should -Match "Get-Module\s+-Name\s+'SPSCleanDependencies\.Common'\)\.Version" } It 'Should import the SqlServer module' { From 5e1f5b3147866346150768479e160e0ab7774f4b Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Thu, 9 Jul 2026 16:09:08 +0200 Subject: [PATCH 4/7] ci: adapt workflows to src/ layout and bump deprecated actions Update the CI workflows for the new src/ module layout and refresh the GitHub Actions that GitHub had deprecated (Node 20 runtime warnings): - pester.yml: trigger on src/** (plus PSScriptAnalyzerSettings.psd1), point PSScriptAnalyzer at ./src/SPSCleanDependencies.ps1 and the ./src/Modules/SPSCleanDependencies.Common module, add the checks/pull-requests permissions and make the publish step continue-on-error; actions/checkout@v4 -> v7, actions/upload-artifact@v4 -> v7. - release.yml: zip the *contents* of src/ (extracts straight to Modules/ and SPSCleanDependencies.ps1) and add contents: write permission; actions/checkout@v4 -> v7, softprops/action-gh-release@v2 -> v3. - wiki.yml: actions/checkout@v4 -> v7. Closes the separate 'CI: bump deprecated GitHub actions' backlog item for this repo. Refs #6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pester.yml | 30 ++++++++++++++++++++---------- .github/workflows/release.yml | 18 +++++++++++++----- .github/workflows/wiki.yml | 4 ++-- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/.github/workflows/pester.yml b/.github/workflows/pester.yml index a430d86..29fefb1 100644 --- a/.github/workflows/pester.yml +++ b/.github/workflows/pester.yml @@ -7,8 +7,17 @@ on: branches: - main paths: - - 'scripts/**' + - 'src/**' - 'tests/**' + - 'PSScriptAnalyzerSettings.psd1' + +# The "Publish Test Results" step creates a check run, which requires write +# access to checks. The default GITHUB_TOKEN is read-only on repositories +# created with the modern default, so grant the minimum needed here. +permissions: + contents: read + checks: write + pull-requests: write jobs: test: @@ -17,7 +26,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install Pester shell: pwsh @@ -38,7 +47,7 @@ jobs: Invoke-Pester -Configuration $config - name: Upload test results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: test-results @@ -47,6 +56,7 @@ jobs: - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action/composite@v2 if: always() + continue-on-error: true with: files: | test-results.xml @@ -58,7 +68,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install PSScriptAnalyzer shell: pwsh @@ -68,16 +78,16 @@ jobs: - name: Run PSScriptAnalyzer shell: pwsh run: | - $scriptFiles = Get-ChildItem -Path ./scripts -Include '*.ps1', '*.psm1' -Recurse - - $results = $scriptFiles | ForEach-Object { - Invoke-ScriptAnalyzer -Path $_.FullName -Severity Error,Warning -Settings ./PSScriptAnalyzerSettings.psd1 - } + # Analyze our own code only: the entry script and the SPSCleanDependencies.Common module. + $results = @() + $results += Invoke-ScriptAnalyzer -Path ./src/SPSCleanDependencies.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 + $results += Invoke-ScriptAnalyzer -Path ./src/Modules/SPSCleanDependencies.Common -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 if ($results) { $results | Format-Table -AutoSize Write-Error "PSScriptAnalyzer found issues" exit 1 - } else { + } + else { Write-Host "No issues found by PSScriptAnalyzer" } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02eacc7..aa3f01c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,8 @@ -# This is a basic workflow to help you get started with Actions +# This is the SPSCleanDependencies CI Release workflow to create a release from a +# tag push. Triggered by pushing a v* tag (e.g. v1.3.0). It packages the *contents* +# of the src/ folder into a ZIP (so the archive extracts straight to Modules/ and +# SPSCleanDependencies.ps1, with no src/ wrapper) and publishes a GitHub Release +# using RELEASE-NOTES.md as the body. name: SPSCleanDependencies CI Release @@ -8,6 +12,9 @@ on: tags: - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 +permissions: + contents: write + jobs: build: runs-on: ubuntu-latest @@ -15,17 +22,17 @@ jobs: # Checkout code - name: Checkout code id: checkout_code - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Create a ZIP file with project name and tag version - - name: Create ZIP file of scripts + - name: Create ZIP file of src contents run: | zip_name="SPSCleanDependencies-${{ github.ref_name }}.zip" - zip -r $zip_name scripts/ + ( cd src && zip -r "../$zip_name" . ) shell: bash # Create Release with tag version - name: Create GitHub Release id: create_release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -35,3 +42,4 @@ jobs: files: | LICENSE SPSCleanDependencies-${{ github.ref_name }}.zip + diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index b3c7a04..cfb7a9d 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -21,13 +21,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: repository: ${{github.repository}} path: ${{github.repository}} - name: Checkout Wiki - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: repository: ${{github.repository}}.wiki path: ${{github.repository}}.wiki From 35a3d48e90fc7c2e4e8416985c3f434f0cf25dca Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Thu, 9 Jul 2026 16:10:09 +0200 Subject: [PATCH 5/7] docs: document the 1.3.0 module refactor Add the 1.3.0 entry to CHANGELOG.md and rewrite RELEASE-NOTES.md for this release: the SPSCleanDependencies.Common module restructure (Public/Private + manifest), the src/ entry-script relocation with manifest-based versioning, the relocated prelude, the test updates and the CI layout/action-bump changes. Also correct wiki/Home.md to describe the WebParts and Orphaned Sites cleanups as implemented (they stopped being placeholders in 1.2.0). Refs #6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 25 +++++++++++++++++++++++++ RELEASE-NOTES.md | 31 ++++++++++++------------------- wiki/Home.md | 3 ++- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf5e6e7..58bf683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,31 @@ The format is based on and uses the types of changes according to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] - 2026-07-09 + +### Changed + +- Restructure the helper module into `src/Modules/SPSCleanDependencies.Common` following the `SPSUpdate` pattern: + - One function per file under `Public/` (`Get-SPSInstalledProductVersion`, `Get-SPSMissingServerDependencies` and the six `Remove-SPS*` cleanups) and the internal SQL helpers under `Private/` (`Get-SQLMissing*`, no longer exported to callers). + - Add a loader `SPSCleanDependencies.Common.psm1` that dot-sources `Private/` + `Public/` and exports only the public functions. + - Add a clean `SPSCleanDependencies.Common.psd1` manifest (`ModuleVersion` `1.3.0`, correct `FunctionsToExport`) replacing the mis-named, never-imported `SPSCleanDependencies.util.util.psd1`. + - Fold the former module-level classes, `ArrayList` collections and `jsonObject` into `Get-SPSMissingServerDependencies` (their only consumer) so the module holds no mutable state. +- Move the entry script to `src/SPSCleanDependencies.ps1` and import the module through its manifest. The script version is now sourced from `(Get-Module SPSCleanDependencies.Common).Version` instead of a hard-coded string. +- Relocate the import-time prelude (administrator check, High Performance power plan and SharePoint snap-in / `SharePointServer` module load) from the module into the entry script, making the module import-safe by design (the `SPSCD_SKIP_PRELUDE` gate is no longer needed). +- Behaviour on a real SharePoint farm is unchanged. [issue #6](https://github.com/luigilink/SPSCleanDependencies/issues/6) + +- Tests: + - Rename the module test file to `tests/Modules/SPSCleanDependencies.Common.Tests.ps1` and adapt it to the new layout (import via the `.psd1` manifest, resolve the internal SQL helpers through `InModuleScope`, assert `FunctionsToExport` matches the exported set, match the `SPMissing*` classes against `Public/Get-SPSMissingServerDependencies.ps1`). + - Point the entry-script tests at `src/SPSCleanDependencies.ps1` and assert the manifest-based import and version sourcing. + +- CI: + - `pester.yml` / `release.yml` / `wiki.yml` adapted to the `src/` layout (workflow `paths`, PSScriptAnalyzer targets, release ZIP of `src/` contents). + - Bump deprecated GitHub Actions (Node 20 runtime warnings): `actions/checkout@v4` -> `v7`, `actions/upload-artifact@v4` -> `v7`, `softprops/action-gh-release@v2` -> `v3`. + +### Fixed + +- Home wiki page: describe the WebParts and Orphaned Sites cleanups as implemented (they stopped being placeholders in 1.2.0). + ## [1.2.0] - 2026-06-11 ### Added diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 8c25f68..613a217 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,27 +1,20 @@ # SPSCleanDependencies - Release Notes -## [1.2.0] - 2026-06-11 +## [1.3.0] - 2026-07-09 -### Added +### Changed -- SPSCleanDependencies.util.psm1: - - Add `Get-SQLMissingWebPartInfo` helper to resolve missing WebPart class IDs to per-page locations. - - Add `Remove-SPSMissingWebPart` cleanup function (uses `GetLimitedWebPartManager` and temporarily clears the site `ReadOnly` flag). - - Extend `SPMissingWebPartInfo` class with location fields (`StorageKey`, `SiteID`, `WebID`, `ListID`, `DirName`, `LeafName`). - - All `Remove-SPS*` functions now support `-WhatIf` / `-Confirm` via `SupportsShouldProcess`. - - Import-time prelude (admin check, `powercfg`, SharePoint snap-in load) is now gated behind the `SPSCD_SKIP_PRELUDE` environment variable so the module can be imported on CI / non-SharePoint hosts. +- Restructure the helper module into `src/Modules/SPSCleanDependencies.Common` (aligned on the `SPSUpdate` pattern): + - One function per file under `Public/`, internal SQL helpers under `Private/` (`Get-SQLMissing*`, no longer exported). + - A loader `.psm1` dot-sourcing `Private/` + `Public/`, and a clean `.psd1` manifest (`ModuleVersion` 1.3.0, correct `FunctionsToExport`) replacing the mis-named, never-imported `SPSCleanDependencies.util.util.psd1`. + - The former module-level classes / `ArrayList` collections / `jsonObject` are folded into `Get-SPSMissingServerDependencies`, so the module holds no mutable state. +- The entry script moves to `src/SPSCleanDependencies.ps1`, imports the module through its manifest, and sources its version from `(Get-Module SPSCleanDependencies.Common).Version`. +- The import-time prelude (administrator check, High Performance power plan, SharePoint snap-in load) moves from the module into the entry script, making the module import-safe by design. +- Behaviour on a real SharePoint farm is unchanged. -- SPSCleanDependencies.ps1: - - Implement the `MissingWebPart` cleanup branch (previously a no-op). - - Implement the `SiteOrphan` cleanup branch by wiring up the existing `Remove-SPSOrphanedSite` function. +### CI -- Pester test suite under `tests/` covering the script and helper module, including `SupportsShouldProcess` coverage on every `Remove-SPS*` function. - -- `.github/workflows/pester.yml` CI workflow running Pester 5.3+ and `PSScriptAnalyzer` on `windows-latest`. - -### Fixed - -- Replace `Write-Host` with `Write-Output` in `Remove-SPSMissingSetupFile` and clear remaining `PSScriptAnalyzer` warnings via a new `PSScriptAnalyzerSettings.psd1` at the repo root (excluding `PSUseSingularNouns` to preserve the public `Get-SPSMissingServerDependencies` name). -- Stop hiding real `Import-Module` failures in the module Pester tests so future regressions surface immediately. +- Workflows adapted to the `src/` layout (`paths`, PSScriptAnalyzer targets, release ZIP of `src/` contents). +- Deprecated GitHub Actions bumped: `actions/checkout@v4` -> `v7`, `actions/upload-artifact@v4` -> `v7`, `softprops/action-gh-release@v2` -> `v3`. A full list of changes in each version can be found in the [change log](CHANGELOG.md) diff --git a/wiki/Home.md b/wiki/Home.md index fa3ddf8..85f1b66 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -9,10 +9,11 @@ SPSCleanDependencies is a PowerShell script tool to clean Missing Server Depende - Reads a JSON configuration file (if it exists) to identify missing dependencies. - Handles the following types of missing dependencies: - Features: Removes missing feature references. - - WebParts: Placeholder for removing missing WebPart references. + - WebParts: Removes missing WebPart references (resolves each WebPart class to its per-page location and removes the instance via `GetLimitedWebPartManager`). - Setup Files: Removes missing setup file references. - Assemblies: Removes missing assembly references. - Configuration: Updates SPSite owners with classic authentication and removes missing configuration references. + - Orphaned Sites: Removes orphaned site references via `ForceDeleteSite`. - Logging: - Creates a log file in the Logs folder with a timestamped filename. - Outputs script metadata, including version, start time, and PowerShell version. From 300b43188bc5b92229221971eac620d53b3d4988 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Thu, 9 Jul 2026 16:14:01 +0200 Subject: [PATCH 6/7] docs(wiki): add sidebar and reference pages Add a _Sidebar.md navigation aligned with the sibling luigilink projects, plus new reference pages: - Release-Process.md: how to ship a version (manifest-driven ModuleVersion, v* tag, release workflow). - Dependency-Types.md: reference for the seven dependency categories the tool detects and cleans, their remediation and the results JSON shape. - Troubleshooting.md: common issues (SqlServer module, elevation, missing JSON, -WhatIf preview, audit-only MissingSiteDefinition, log locations). Cross-link the new pages from Home.md and Usage.md, and correct the Home key features list. Refs #6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- wiki/Dependency-Types.md | 97 ++++++++++++++++++++++++++++++++++++++++ wiki/Home.md | 3 ++ wiki/Release-Process.md | 97 ++++++++++++++++++++++++++++++++++++++++ wiki/Troubleshooting.md | 65 +++++++++++++++++++++++++++ wiki/Usage.md | 5 +++ wiki/_Sidebar.md | 17 +++++++ 6 files changed, 284 insertions(+) create mode 100644 wiki/Dependency-Types.md create mode 100644 wiki/Release-Process.md create mode 100644 wiki/Troubleshooting.md create mode 100644 wiki/_Sidebar.md diff --git a/wiki/Dependency-Types.md b/wiki/Dependency-Types.md new file mode 100644 index 0000000..ccf5f7b --- /dev/null +++ b/wiki/Dependency-Types.md @@ -0,0 +1,97 @@ +# Dependency Types + +SPSCleanDependencies runs `Test-SPContentDatabase -ExtendedCheck` against every content database and groups the reported issues into categories. In **audit** mode each category is written to the results JSON file; with the `-Clean` switch each category is remediated by a dedicated function. + +This page documents every category, what it means, how the tool remediates it, and how it is represented in the results JSON. + +## Overview + +| Category (JSON key) | Detected by | Cleanup function | SharePoint object acted on | +|---|---|---|---| +| `MissingFeature` | `Test-SPContentDatabase` | `Remove-SPSMissingFeature` | Site collection + all webs | +| `MissingWebPart` | `Test-SPContentDatabase` + SQL lookup | `Remove-SPSMissingWebPart` | Web part on a specific page | +| `MissingSetupFile` | `Test-SPContentDatabase` + SQL lookup | `Remove-SPSMissingSetupFile` | Ghosted file in a web | +| `MissingAssembly` | `Test-SPContentDatabase` + SQL lookup | `Remove-SPSMissingAssembly` | Event receiver on site/web/list | +| `Configuration` | `Test-SPContentDatabase` + SQL lookup | `Remove-SPSMissingConfiguration` | Classic-auth site administrators | +| `MissingSiteDefinition` | `Test-SPContentDatabase` | _(audit only — reported, not removed)_ | — | +| `SiteOrphan` | `Test-SPContentDatabase` | `Remove-SPSOrphanedSite` | Orphaned site collection | + +> [!NOTE] +> The SQL lookups query the content database directly (read-only, `WITH (NOLOCK)`) to resolve the exact locations that `Test-SPContentDatabase` does not return, so the cleanup can target a precise object rather than scanning the whole farm. + +## MissingFeature + +A feature is activated on a site collection or web, but its definition is no longer installed on the farm (the feature folder was removed with the solution). + +- **Remediation:** `Remove-SPSMissingFeature` walks the site collection and every web and calls `Features.Remove($featureID, $true)` wherever the orphaned feature is found. +- **JSON fields:** `Database`, `Category`, `FeatureID`, `Message`, `Remedy`, `SiteID`, `Path`. + +## MissingWebPart + +A page references a web part class that is no longer deployed on the farm. + +- **Remediation:** `Remove-SPSMissingWebPart` resolves the web part class ID to its per-page location via SQL (`AllDocs` + `AllWebParts`), opens the page with `GetLimitedWebPartManager`, and deletes the specific instance identified by its `StorageKey`. The site `ReadOnly` flag is temporarily cleared and restored around the deletion. +- **Safety net:** if `StorageKey` is empty (no specific instance to remove) the function returns without changing anything. +- **JSON fields:** `Database`, `Category`, `WebPartID`, `Message`, `Remedy`, `ClassName`, `StorageKey`, `SiteID`, `WebID`, `ListID`, `DirName`, `LeafName`. + +## MissingSetupFile + +A content item points at a setup (template) file on disk (`SetupPath`) that no longer exists on the servers — a "ghosted" file left behind by a removed solution. + +- **Remediation:** `Remove-SPSMissingSetupFile` resolves the file via SQL (`AllDocs` on `SetupPath`), opens it with `SPWeb.GetFile()` and calls `Delete()` (which bypasses the recycle bin). +- **JSON fields:** `Database`, `Category`, `Message`, `Remedy`, `SetupPath`, `FileID`, `SiteID`, `WebID`. + +## MissingAssembly + +An event receiver references an assembly that is no longer present in the GAC / bin. + +- **Remediation:** `Remove-SPSMissingAssembly` resolves the receiver via SQL (`EventReceivers`) and removes it from the correct host, dispatched on `HostType`: + - `0` → `SPSite` + - `1` → `SPWeb` + - `2` → `SPList` +- **JSON fields:** `Database`, `Category`, `Message`, `Remedy`, `AssemblyInfo`, `AssemblyID`, `HostID`, `HostType`, `SiteID`, `WebID`. + +## Configuration + +Reported when a site administrator entry uses classic (non-claims) authentication. + +- **Remediation:** `Remove-SPSMissingConfiguration` removes the offending login from the `SiteAdministrators` collection of each web. In addition, the entry script rewrites the owner of any site collection whose `Owner` is still a classic account (`Owner -notlike 'i:0#.w|*'`, excluding `sitemaster-*`) to the farm default service account before the configuration entries are cleaned. +- **JSON fields:** `Database`, `Category`, `Message`, `Remedy`, `SiteID`, `Login`. + +## MissingSiteDefinition + +A site collection references a site definition (webtemp) that is no longer installed. + +- **Remediation:** **audit only.** The category is detected and written to the JSON for visibility, but there is no automatic removal — a missing site definition usually requires re-installing the template or migrating the affected sites, which must be decided by an administrator. +- **JSON fields:** `Database`, `Category`, `Message`, `Remedy`. + +## SiteOrphan + +A site collection exists in the content database but is not registered in the configuration database (or vice versa). + +- **Remediation:** `Remove-SPSOrphanedSite` calls `SPContentDatabase.ForceDeleteSite($siteID, $false, $false)` to remove the orphaned site from the database. +- **JSON fields:** `Database`, `Category`, `Message`, `Remedy`, `SiteID`. + +## Results JSON shape + +In audit mode the results file (`Results\.json`) is a single object whose properties are the categories that were found. Each property is an array of the objects described above: + +```json +{ + "MissingFeature": [ { "Database": "...", "FeatureID": "...", "SiteID": "..." } ], + "MissingWebPart": [ { "Database": "...", "WebPartID": "...", "StorageKey": "...", "DirName": "...", "LeafName": "..." } ], + "MissingSetupFile": [ { "Database": "...", "FileID": "...", "SiteID": "...", "WebID": "..." } ], + "MissingAssembly": [ { "Database": "...", "AssemblyID": "...", "HostType": "0", "HostID": "..." } ], + "Configuration": [ { "Database": "...", "SiteID": "...", "Login": "..." } ], + "MissingSiteDefinition":[ { "Database": "...", "Message": "...", "Remedy": "..." } ], + "SiteOrphan": [ { "Database": "...", "SiteID": "..." } ] +} +``` + +> [!IMPORTANT] +> The results file is the exact input consumed by the `-Clean` run. Always review it (and back up your content databases) before cleaning. See [Usage](Usage). + +## See also + +- [Usage](Usage) +- [Troubleshooting](Troubleshooting) diff --git a/wiki/Home.md b/wiki/Home.md index 85f1b66..1e6f4e4 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -22,3 +22,6 @@ For details on usage, configuration, and parameters, explore the links below: - [Getting Started](./Getting-Started) - [Usage](./Usage) +- [Dependency Types](./Dependency-Types) +- [Troubleshooting](./Troubleshooting) +- [Release Process](./Release-Process) diff --git a/wiki/Release-Process.md b/wiki/Release-Process.md new file mode 100644 index 0000000..86b1d3e --- /dev/null +++ b/wiki/Release-Process.md @@ -0,0 +1,97 @@ +# Release Process + +This page documents how to ship a new version of SPSCleanDependencies. The process is centered on a single source of truth — the `ModuleVersion` field of `SPSCleanDependencies.Common.psd1` — and a `v*` git tag that triggers the GitHub release workflow. + +## Versioning policy + +SPSCleanDependencies follows [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html). + +| Bump | When | +|---|---| +| MAJOR (X.0.0) | Breaking change in the package layout, the results JSON schema, or a public module function signature. | +| MINOR (X.Y.0) | New backward-compatible feature (new cleanup branch, new public function, new optional parameter). | +| PATCH (X.Y.Z) | Bug fix or documentation-only change. | + +## Release checklist + +### 1. Bump the version + +Edit **one** value in `src/Modules/SPSCleanDependencies.Common/SPSCleanDependencies.Common.psd1`: + +```powershell +ModuleVersion = '1.3.0' # was '1.2.0' +``` + +This single change propagates automatically to: + +- The script banner (`$SPSCleanDependenciesVersion` is read from `(Get-Module SPSCleanDependencies.Common).Version`) +- The `Get-Module SPSCleanDependencies.Common` version surfaced to users + +### 2. Update `CHANGELOG.md` + +Add a dated section for the version being released, following [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +### 3. Replace `RELEASE-NOTES.md` + +`RELEASE-NOTES.md` is used **verbatim** as the body of the GitHub Release. It must contain **only** the section of the version being released (no stacked history). + +### 4. Validate locally + +```powershell +Import-Module .\src\Modules\SPSCleanDependencies.Common\SPSCleanDependencies.Common.psd1 -Force +(Get-Module SPSCleanDependencies.Common).Version # should match the bumped version +Invoke-Pester -Path .\tests +Invoke-ScriptAnalyzer -Path .\src -Recurse -Settings .\PSScriptAnalyzerSettings.psd1 +``` + +### 5. Commit on a release branch + +```bash +git checkout -b Release/1.3.0 +git add -A +git commit -m "release: v1.3.0" +git push -u origin Release/1.3.0 +``` + +Test the branch ZIP on a real farm first, then open a Pull Request, review, and merge to `main`. + +### 6. Tag from `main` + +```bash +git checkout main +git pull +git tag v1.3.0 +git push origin v1.3.0 +``` + +The `.github/workflows/release.yml` workflow runs automatically. It: + +1. Packages the **contents** of `src/` into `SPSCleanDependencies-v1.3.0.zip` (the archive extracts straight to `SPSCleanDependencies.ps1` and `Modules\`, with no `src/` wrapper). +2. Publishes a GitHub Release using `RELEASE-NOTES.md` as the body. +3. Attaches the ZIP and `LICENSE` to the release. + +### 7. Verify + +- **Releases**: — the new release is listed with the expected body and ZIP. +- **Actions**: — `release.yml` and `pester.yml` ran green. +- **Wiki**: — `wiki.yml` synced any `wiki/` changes pushed in the same release. + +## Undoing a release + +If you tagged too early: + +```bash +git tag -d v1.3.0 +git push origin --delete v1.3.0 +``` + +Then delete the auto-created Release on GitHub, fix what needs fixing, commit, and re-tag from the new HEAD. + +> ⚠️ **Don't move a published tag** that has been live for more than a few minutes. Prefer publishing a `vX.Y.(Z+1)` patch release instead of rewriting `vX.Y.Z`. + +## See also + +- [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +- [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html) +- [Getting Started](Getting-Started) +- [Usage](Usage) diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md new file mode 100644 index 0000000..149cbc5 --- /dev/null +++ b/wiki/Troubleshooting.md @@ -0,0 +1,65 @@ +# Troubleshooting + +Common issues when running SPSCleanDependencies and how to resolve them. + +## `Invoke-Sqlcmd is not recognized` / SQL lookups fail + +The tool resolves web part, setup file, assembly and configuration locations by querying the content database with `Invoke-Sqlcmd`, which ships with the **SqlServer** PowerShell module. + +- Install it from the PowerShell Gallery: + + ```powershell + Install-Module -Name SqlServer -Scope AllUsers + ``` + +- The entry script imports it at startup and exits early if it is missing. See [Getting Started → Prerequisites](Getting-Started). + +## `Administrator rights are required` + +The script must run in an **elevated** PowerShell session (it loads the SharePoint snap-in, reads content databases and, in `-Clean` mode, modifies farm objects). Re-run PowerShell as Administrator. + +## SharePoint cmdlets are not available + +The entry script loads the SharePoint management shell for you: + +- SharePoint 2016 (build ≤ 12999) → adds the `Microsoft.SharePoint.PowerShell` PSSnapin. +- SharePoint 2019 / Subscription Edition → imports the `SharePointServer` module. + +Run the script **on a SharePoint server** with the management tools installed, using a farm account (or an account that is a member of the Farm Administrators group and has the `SharePoint_Shell_Access` role on the content databases). + +## Nothing happens / `Missing .json` + +`-Clean` consumes the results file produced by a prior audit run. If it is missing you will get: + +``` +Missing \Results\.json +Please re-run this script without the switch parameter Clean +``` + +Run the audit first (no `-Clean`), review the JSON, then run `-Clean` with the **same** `-FileName`. See [Usage](Usage). + +## Preview a cleanup without changing anything + +Every `Remove-SPS*` function supports `ShouldProcess`, so you can dry-run the module functions with `-WhatIf` (or force a prompt with `-Confirm`) before committing to a change: + +```powershell +Import-Module .\src\Modules\SPSCleanDependencies.Common\SPSCleanDependencies.Common.psd1 +Remove-SPSMissingFeature -Database 'WSS_Content' -FeatureID '...' -SiteID '...' -WhatIf +``` + +## A missing site definition was reported but not removed + +This is expected — `MissingSiteDefinition` is **audit only**. See [Dependency Types → MissingSiteDefinition](Dependency-Types#missingsitedefinition). A missing site definition normally requires re-installing the template or migrating the affected sites, which is an administrator decision, not an automatic deletion. + +## Where are the logs? + +Each run starts a transcript under `Logs\_.log` next to the script, and the results are written to `Results\.json`. Both folders are created automatically on first run. + +> [!IMPORTANT] +> Back up your content databases and test on a non-production farm before running `-Clean`. The cleanup functions delete farm objects and, for setup files, bypass the recycle bin. + +## See also + +- [Getting Started](Getting-Started) +- [Usage](Usage) +- [Dependency Types](Dependency-Types) diff --git a/wiki/Usage.md b/wiki/Usage.md index d1bd539..df32808 100644 --- a/wiki/Usage.md +++ b/wiki/Usage.md @@ -25,3 +25,8 @@ Remove missing server side dependencies on SharePoint farm: ```powershell .\SPSCleanDependencies.ps1 -FileName 'CONTOSO-PROD-SPSE' -Clean ``` + +## Next Steps + +- [Dependency Types](./Dependency-Types) — the categories detected and cleaned, and the results JSON shape. +- [Troubleshooting](./Troubleshooting) — common issues and how to resolve them. diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md new file mode 100644 index 0000000..6a9a516 --- /dev/null +++ b/wiki/_Sidebar.md @@ -0,0 +1,17 @@ +**Navigation** + +- [🏠 Home](Home) +- [🚀 Getting Started](Getting-Started) +- [📖 Usage](Usage) +- [🧩 Dependency Types](Dependency-Types) +- [🛠️ Troubleshooting](Troubleshooting) +- [📦 Release Process](Release-Process) + +--- + +**Project** + +- [Repository](https://github.com/luigilink/SPSCleanDependencies) +- [Releases](https://github.com/luigilink/SPSCleanDependencies/releases) +- [Issues](https://github.com/luigilink/SPSCleanDependencies/issues) +- [Changelog](https://github.com/luigilink/SPSCleanDependencies/blob/main/CHANGELOG.md) From 1918a9527a6a02aa4451dacdc9bae4691c0921c9 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Thu, 9 Jul 2026 16:17:40 +0200 Subject: [PATCH 7/7] style: use single interpolated strings for positional Write-Output Combine the two-argument (positional) Write-Output calls in Remove-SPSMissingFeature/Configuration/SetupFile into single interpolated strings, clearing the PSAvoidUsingPositionalParameters findings that failed the Code Quality job on Windows PowerShell (PSScriptAnalyzer reports these at Information severity, which the CI job treats as a failure). No behaviour change. Refs #6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Public/Remove-SPSMissingConfiguration.ps1 | 2 +- .../Public/Remove-SPSMissingFeature.ps1 | 10 +++++----- .../Public/Remove-SPSMissingSetupFile.ps1 | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingConfiguration.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingConfiguration.ps1 index 5d062e4..2391b1f 100644 --- a/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingConfiguration.ps1 +++ b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingConfiguration.ps1 @@ -25,7 +25,7 @@ function Remove-SPSMissingConfiguration { $site = Get-SPSite -limit all -Identity $siteID if ($null -ne $site) { - Write-Output "Checking SPSite:" $site.Url + Write-Output "Checking SPSite: $($site.Url)" $webs = Get-SPWeb -Site $siteID -Limit ALL if ($null -ne $webs) { foreach ($web in $webs) { diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingFeature.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingFeature.ps1 index 5169191..bce52a0 100644 --- a/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingFeature.ps1 +++ b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingFeature.ps1 @@ -25,28 +25,28 @@ function Remove-SPSMissingFeature { #Display site information $site = Get-SPSite $SiteID -ErrorAction SilentlyContinue if ($null -ne $site) { - Write-Output "Checking SPSite:" $site.Url + Write-Output "Checking SPSite: $($site.Url)" #Remove the feature from all subsites ForEach ($web in $Site.AllWebs) { if ($web.Features[$featureID]) { - Write-Output "`nFound Feature $featureID in web:"$Web.Url"`nRemoving feature" + Write-Output "`nFound Feature $featureID in web: $($Web.Url)`nRemoving feature" if ($PSCmdlet.ShouldProcess($Web.Url, "Remove feature $featureID")) { $web.Features.Remove($featureID, $true) } } else { - Write-Output "`nDid not find feature $featureID in web:" $Web.Url + Write-Output "`nDid not find feature $featureID in web: $($Web.Url)" } } #Remove the feature from the site collection if ($Site.Features[$featureID]) { - Write-Output "`nFound feature $featureID in site:"$site.Url"`nRemoving Feature" + Write-Output "`nFound feature $featureID in site: $($site.Url)`nRemoving Feature" if ($PSCmdlet.ShouldProcess($site.Url, "Remove feature $featureID")) { $site.Features.Remove($featureID, $true) } } else { - Write-Output "Did not find feature $featureID in site:" $site.Url + Write-Output "Did not find feature $featureID in site: $($site.Url)" } } else { diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingSetupFile.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingSetupFile.ps1 index bca34d6..13a7491 100644 --- a/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingSetupFile.ps1 +++ b/src/Modules/SPSCleanDependencies.Common/Public/Remove-SPSMissingSetupFile.ps1 @@ -30,7 +30,7 @@ function Remove-SPSMissingSetupFile { #Display site information $site = Get-SPSite $SiteID -ErrorAction SilentlyContinue if ($null -ne $site) { - Write-Output "Checking SPSite:" $site.Url + Write-Output "Checking SPSite: $($site.Url)" $web = Get-SPWeb -Identity $WebID -Site $siteID -Limit ALL if ($null -ne $web) { Write-Output "Checking SPWeb Object ID: $WebID"