Usuwanie pól z pakietu SharePoint .wsp bez Visual Studio
Problem
Szablon witryny SharePoint (.wsp) czasami niesie ze sobą definicje pól, o które nikt nie prosił. Wyłączenie funkcji nie sprząta po sobie — pola, które ta funkcja dodała, zostają na każdej witrynie, na której się pojawiły. Po prostu tam siedzą, nieaktywne, i są przenoszone dalej przy każdym kolejnym zapisie tej witryny jako szablonu .wsp.
Dokładnie tak było z PerformancePoint. Ta funkcja nie jest dostępna w SharePoint Subscription Edition, więc jeśli na farmie on-premise kiedykolwiek była włączona, definicje pól PerformancePoint nadal jadą w .wsp nawet po jej wyłączeniu. Spróbuj utworzyć nową witrynę z takiego szablonu na SE, a operacja kończy się błędem — funkcja, do której te pola należą, tam po prostu nie istnieje.
Rozwiązaniem jest usunięcie tych definicji pól z .wsp przed migracją. Nie ma tu przycisku „Usuń pole" — plik .wsp to archiwum CAB, a definicje pól to zwykłe elementy <Field ... /> rozrzucone po plikach XML, jakie akurat zawiera dane rozwiązanie. Robienie tego ręcznie w Visual Studio, albo jeszcze gorzej — ręcznie przez expand.exe i makecab.exe — szybko się nudzi, jeśli trzeba to powtórzyć na kilku szablonach.
Napisałem więc skrypt, który robi całą tę operację od początku do końca: rozpakowanie, usunięcie, ponowne spakowanie.
Co robi skrypt
Remove-WspFields.ps1 przyjmuje ścieżkę do .wsp oraz GroupName i wykonuje cztery kroki:
- Rozpakowuje CAB za pomocą
expand.exe, zachowując dokładnie taką strukturę folderów, jakiej oczekuje SharePoint. - Skanuje każdy plik
.xmlw rozpakowanym drzewie w poszukiwaniu linii zawierającychGroup="<GroupName>"i usuwa te linie. - Odbudowuje CAB za pomocą
makecab.exe, korzystając z wygenerowanego pliku.ddf, dzięki czemu każdy rozpakowany plik wraca na swoją oryginalną ścieżkę wewnętrzną. - Zapisuje wynik, wcześniej tworząc kopię zapasową oryginalnego
.wsp(chyba że podasz-NoBackup).
Jeśli którakolwiek pasująca linia nie jest samodzielnym, jednoliniowym elementem <Field ... />, skrypt zatrzymuje się i rzuca wyjątkiem zamiast zgadywać — nie obsłuży automatycznie wieloliniowych definicji pól, ale też nie uszkodzi żadnej po cichu.
Odbudowa CAB-a przechodzi przez wygenerowany plik .ddf z listą każdego rozpakowanego pliku obok jego oryginalnej ścieżki wewnętrznej, przekazywany do makecab.exe. To właśnie zachowanie dokładnie tego samego mapowania ścieżek — takich samych ścieżek względnych jak przy rozpakowaniu — sprawia, że odbudowany .wsp ma strukturę wewnętrzną identyczną z tą, jakiej oczekuje SharePoint.
.\Remove-WspFields.ps1 -WspPath .\Solution.wsp
Domyślnie skrypt celuje w grupę PerformancePoint i nadpisuje plik wejściowy w miejscu, zostawiając obok niego kopię .bak. Oba te ustawienia można nadpisać:
.\Remove-WspFields.ps1 `
-WspPath .\template.wsp `
-GroupName "PerformancePoint" `
-OutputPath .\template-clean.wsp `
-KeepExtracted
-KeepExtracted pomija krok czyszczenia, dzięki czemu można później zajrzeć do rozpakowanego XML-a — przydatne przy pierwszym uruchomieniu na nowym szablonie, zanim nabierze się do niego zaufania.
Skrypt
<#
.SYNOPSIS
Removes Field definitions matching a given Group attribute from a SharePoint
.wsp (CAB) site template and rebuilds the package.
.DESCRIPTION
1. Extracts the .wsp with expand.exe, preserving the internal folder structure.
2. Removes every single-line <Field ... Group="<GroupName>" ... /> entry from
any XML file that contains it.
3. Rebuilds the CAB with makecab.exe using the same internal paths.
4. Writes the result to -OutputPath (default: overwrites the input .wsp;
a .bak copy of the original is kept next to it unless -NoBackup).
.EXAMPLE
.\Remove-WspFields.ps1 -WspPath .\Solution.wsp
.EXAMPLE
.\Remove-WspFields.ps1 -WspPath .\template.wsp -GroupName "PerformancePoint" -OutputPath .\template-clean.wsp -KeepExtracted
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$WspPath,
[string]$GroupName = 'PerformancePoint',
[string]$OutputPath,
[switch]$NoBackup,
# Keep the extracted working folder next to the wsp instead of deleting it
[switch]$KeepExtracted
)
$ErrorActionPreference = 'Stop'
$WspPath = (Resolve-Path $WspPath).Path
if (-not $OutputPath) { $OutputPath = $WspPath }
$wspName = [IO.Path]::GetFileNameWithoutExtension($WspPath)
# --- 1. Extract -------------------------------------------------------------
$extractDir = Join-Path ([IO.Path]::GetDirectoryName($WspPath)) ("_extract_" + $wspName)
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
New-Item -ItemType Directory -Path $extractDir | Out-Null
Write-Host "Extracting '$WspPath' -> '$extractDir'"
& "$env:SystemRoot\System32\expand.exe" -F:* $WspPath $extractDir | Out-Null
if ($LASTEXITCODE -ne 0) { throw "expand.exe failed with exit code $LASTEXITCODE" }
# --- 2. Remove matching Field lines ----------------------------------------
$pattern = "Group=`"$GroupName`""
$totalRemoved = 0
Get-ChildItem $extractDir -Recurse -Filter *.xml | ForEach-Object {
$lines = [IO.File]::ReadAllLines($_.FullName)
$hits = @($lines | Where-Object { $_ -match [regex]::Escape($pattern) })
if ($hits.Count -eq 0) { return }
# Safety: only handle self-contained single-line <Field ... /> entries
$bad = @($hits | Where-Object { $_ -notmatch '<Field\b.*/>\s*$' })
if ($bad.Count -gt 0) {
throw "File '$($_.FullName)' contains $pattern on a line that is not a single-line <Field ... /> - manual review needed."
}
$kept = $lines | Where-Object { $_ -notmatch [regex]::Escape($pattern) }
# Preserve UTF-8 BOM if the original had one
$origBytes = [IO.File]::ReadAllBytes($_.FullName)
$hasBom = $origBytes.Length -ge 3 -and $origBytes[0] -eq 0xEF -and $origBytes[1] -eq 0xBB -and $origBytes[2] -eq 0xBF
$enc = New-Object System.Text.UTF8Encoding($hasBom)
[IO.File]::WriteAllLines($_.FullName, $kept, $enc)
Write-Host (" {0}: removed {1} field(s)" -f $_.FullName.Substring($extractDir.Length + 1), $hits.Count)
$script:totalRemoved += $hits.Count
}
Write-Host "Total fields removed: $totalRemoved"
# --- 3. Rebuild CAB ---------------------------------------------------------
$cabName = "$wspName.cab"
$buildDir = Join-Path $extractDir '_cabout'
New-Item -ItemType Directory -Path $buildDir | Out-Null
$ddf = New-Object System.Collections.Generic.List[string]
$ddf.Add('.OPTION EXPLICIT')
$ddf.Add(".Set CabinetNameTemplate=$cabName")
$ddf.Add(".Set DiskDirectory1=$buildDir")
$ddf.Add('.Set CompressionType=MSZIP')
$ddf.Add('.Set Cabinet=ON')
$ddf.Add('.Set Compress=ON')
$ddf.Add('.Set UniqueFiles=OFF')
$ddf.Add('.Set MaxDiskSize=0')
$ddf.Add('.Set MaxCabinetSize=0')
$ddf.Add('.Set FolderSizeThreshold=0')
Get-ChildItem $extractDir -Recurse -File |
Where-Object { $_.FullName -notlike "$buildDir*" } |
ForEach-Object {
$internal = $_.FullName.Substring($extractDir.Length + 1)
$ddf.Add("`"$($_.FullName)`" `"$internal`"")
}
$ddfPath = Join-Path $extractDir 'build.ddf'
[IO.File]::WriteAllLines($ddfPath, $ddf, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "Building CAB..."
Push-Location $extractDir
try {
& "$env:SystemRoot\System32\makecab.exe" /F $ddfPath | Out-Null
if ($LASTEXITCODE -ne 0) { throw "makecab.exe failed with exit code $LASTEXITCODE" }
}
finally { Pop-Location }
$builtCab = Join-Path $buildDir $cabName
if (-not (Test-Path $builtCab)) { throw "Expected CAB not found: $builtCab" }
# --- 4. Deliver output ------------------------------------------------------
if ((Test-Path $OutputPath) -and -not $NoBackup) {
Copy-Item $OutputPath "$OutputPath.bak" -Force
Write-Host "Backup written: $OutputPath.bak"
}
Move-Item $builtCab $OutputPath -Force
Write-Host "Output written: $OutputPath"
if (-not $KeepExtracted) {
Remove-Item $extractDir -Recurse -Force
} else {
Write-Host "Extracted files kept in: $extractDir"
}
GroupName nie jest na sztywno przypisane do PerformancePoint — możesz podać dowolną nazwę grupy, a skrypt usunie odpowiadające jej pola. Dzięki temu sprawdza się też jako uniwersalne narzędzie do porządkowania pozostałości po dowolnej innej wycofanej funkcji przed migracją.
Mały skrypt, ale zamienił żmudne „otwórz Visual Studio, znajdź pola, usuń je, wdróż rozwiązanie ponownie" w jedną linijkę polecenia.