Files
Тимур Городецкий 40839cc8d8 feat: Compare-Manifests.ps1 — сверка манифестов с живыми списками (#2)
Манифесты отстают от живых списков, когда колонки добавляют руками или кодом,
и пересоздание по ним ломает функции. Скрипт только читает и показывает, чего
нет в манифесте, чего нет на сайте и где отличаются тип, варианты или лукап.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 14:29:24 +05:00

163 lines
7.6 KiB
PowerShell

<#
.SYNOPSIS
Сверяет манифесты provisioning.json с живыми списками сайта. Только чтение.
.DESCRIPTION
Манифесты снимались с живых списков (Export-ListSchema.ps1), но колонки потом добавляют
руками и кодом — манифест отстаёт, и пересоздание списков по нему ломает функции.
Запускать перед Provision-Lists.ps1 на рабочем сайте и после ручных правок списков.
Показывает по каждому списку:
- в манифесте, НЕТ в живом — колонку создаст Provision-Lists.ps1;
+ в живом, НЕТ в манифесте — манифест отстал: при пересоздании колонки не будет;
~ тип / варианты / лукап — колонка есть, но отличается.
.PARAMETER SiteUrl
Сайт со списками, например https://tnsukz.sharepoint.com/sites/sed
.PARAMETER ClientId
Client Id приложения с доступом к сайту (app-only).
.PARAMETER Tenant
Тенант, например tnsukz.onmicrosoft.com
.PARAMETER Thumbprint
Отпечаток сертификата в хранилище CurrentUser\My. Либо он, либо CertificatePath.
.PARAMETER CertificatePath
Путь к файлу сертификата .pfx — если сертификат не установлен в хранилище.
.PARAMETER CertificatePassword
Пароль к .pfx из CertificatePath.
.PARAMETER ManifestsDir
Папка с манифестами. По умолчанию — provisioning/manifests рядом со скриптом.
.PARAMETER Only
Сверять только манифесты указанных форм (по полю form). По умолчанию — все.
.EXAMPLE
./Compare-Manifests.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/sed" `
-ClientId "<id>" -Tenant "tnsukz.onmicrosoft.com" -CertificatePath "<pfx>" -CertificatePassword "<пароль>"
#>
param(
[Parameter(Mandatory = $true)][string]$SiteUrl,
[Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant,
[string]$Thumbprint,
[string]$CertificatePath,
[string]$CertificatePassword,
[string]$ManifestsDir = "$PSScriptRoot/manifests",
[string[]]$Only
)
$ErrorActionPreference = 'Stop'
# Служебные поля SharePoint и Syntex: в манифестах их нет и быть не должно — в «+ в живом» не показываем.
$SYSTEM_FIELDS = @(
'Title', 'ContentType', 'Attachments', 'Edit', 'LinkTitle', 'LinkTitleNoMenu', 'DocIcon',
'ItemChildCount', 'FolderChildCount', 'AppAuthor', 'AppEditor', 'ComplianceAssetId',
'_ColorTag', '_ComplianceFlags', '_ComplianceTag', '_ComplianceTagWrittenTime', '_ComplianceTagUserId',
'_IsRecord', 'FileLeafRef', '_DisplayName', 'SelectTitle', 'FileRef', '_UIVersionString',
'ParentVersionString', 'ParentLeafName', 'CheckoutUser', 'MediaServiceImageTags',
'_ExtendedDescription', '_ModelId', '_dlc_DocId', '_dlc_DocIdUrl'
)
$SYNTEX_FIELD_PREFIX = 'Prime'
# Тип живой колонки → тип манифеста (Currency/MultiChoice/UserMulti/LookupMulti сводятся к базовому).
$TYPE_MAP = @{
'Text' = 'Text'; 'Note' = 'Note'; 'Number' = 'Number'; 'Currency' = 'Number';
'Choice' = 'Choice'; 'MultiChoice' = 'Choice'; 'DateTime' = 'DateTime'; 'Boolean' = 'Boolean';
'User' = 'User'; 'UserMulti' = 'User'; 'Lookup' = 'Lookup'; 'LookupMulti' = 'Lookup'
}
$listTitleById = @{}
function Get-ListTitleById {
param([string]$Id)
if (-not $listTitleById.ContainsKey($Id)) {
$listTitleById[$Id] = (Get-PnPList -Identity $Id -ErrorAction SilentlyContinue).Title
}
return $listTitleById[$Id]
}
function Compare-Column {
param($Col, $Field)
$diffs = @()
$liveType = $TYPE_MAP[$Field.TypeAsString]
if (-not $liveType) { $liveType = $Field.TypeAsString }
if ($liveType -ne $Col.type) {
$diffs += " ~ тип $($Col.internalName): манифест $($Col.type), живой $($Field.TypeAsString)"
}
if ($Col.type -eq 'Choice') {
$liveChoices = @($Field.Choices)
$manifestChoices = @($Col.choices)
$onlyLive = @($liveChoices | Where-Object { $manifestChoices -notcontains $_ })
$onlyManifest = @($manifestChoices | Where-Object { $liveChoices -notcontains $_ })
if ($onlyLive.Count -gt 0) { $diffs += " ~ варианты $($Col.internalName): только в живом — $($onlyLive -join ', ')" }
if ($onlyManifest.Count -gt 0) { $diffs += " ~ варианты $($Col.internalName): только в манифесте — $($onlyManifest -join ', ')" }
}
if ($Col.type -eq 'Lookup') {
$schema = [xml]$Field.SchemaXml
$target = Get-ListTitleById ($schema.Field.List -replace '[{}]', '')
if ($target -ne $Col.lookupList) {
$diffs += " ~ лукап $($Col.internalName): манифест → «$($Col.lookupList)», живой → «$target»"
}
if ($schema.Field.ShowField -and $schema.Field.ShowField -ne $Col.lookupShowField) {
$diffs += " ~ лукап $($Col.internalName): показ манифест $($Col.lookupShowField), живой $($schema.Field.ShowField)"
}
}
return $diffs
}
if ($Thumbprint) {
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
}
elseif ($CertificatePath) {
$securePassword = ConvertTo-SecureString $CertificatePassword -AsPlainText -Force
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -CertificatePath $CertificatePath -CertificatePassword $securePassword
}
else { throw "Укажите -Thumbprint или -CertificatePath" }
$totalDiffs = 0
foreach ($file in Get-ChildItem -Path $ManifestsDir -Filter '*.provisioning.json') {
$manifest = Get-Content -Path $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
if ($Only -and ($Only -notcontains $manifest.form)) { continue }
Write-Host "`n######## $($manifest.form)" -ForegroundColor Cyan
foreach ($ls in $manifest.lists) {
if (-not (Get-PnPList -Identity $ls.title -ErrorAction SilentlyContinue)) {
Write-Host "== «$($ls.title)»: списка НЕТ на сайте" -ForegroundColor Yellow
$totalDiffs++
continue
}
$allFields = Get-PnPField -List $ls.title
$diffs = @()
foreach ($col in $ls.columns) {
$field = $allFields | Where-Object { $_.InternalName -eq $col.internalName } | Select-Object -First 1
if (-not $field) { $diffs += " - в манифесте, НЕТ в живом: $($col.internalName) [$($col.type)]"; continue }
$diffs += Compare-Column -Col $col -Field $field
}
$customFields = $allFields | Where-Object {
-not $_.Hidden -and -not $_.ReadOnlyField -and -not $_.FromBaseType -and
$SYSTEM_FIELDS -notcontains $_.InternalName -and -not $_.InternalName.StartsWith($SYNTEX_FIELD_PREFIX)
}
foreach ($field in $customFields) {
if (-not ($ls.columns | Where-Object { $_.internalName -eq $field.InternalName })) {
$diffs += " + в живом, НЕТ в манифесте: $($field.InternalName) «$($field.Title)» [$($field.TypeAsString)]"
}
}
if ($diffs.Count -eq 0) {
Write-Host "== «$($ls.title)»: совпадает" -ForegroundColor DarkGray
}
else {
Write-Host "== «$($ls.title)»:" -ForegroundColor White
$diffs | ForEach-Object { Write-Host $_ }
$totalDiffs += $diffs.Count
}
}
}
Write-Host "`nРасхождений: $totalDiffs" -ForegroundColor Cyan
Disconnect-PnPOnline