feat: Compare-Manifests.ps1 — сверка манифестов с живыми списками (#2)
Манифесты отстают от живых списков, когда колонки добавляют руками или кодом, и пересоздание по ним ломает функции. Скрипт только читает и показывает, чего нет в манифесте, чего нет на сайте и где отличаются тип, варианты или лукап. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
|||||||
|
<#
|
||||||
|
.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
|
||||||
@@ -103,6 +103,27 @@ cd provisioning
|
|||||||
|
|
||||||
Лог: `[+]` создано, `[=]` уже было, `[~]` обновлено, `[!]` предупреждение.
|
Лог: `[+]` создано, `[=]` уже было, `[~]` обновлено, `[!]` предупреждение.
|
||||||
|
|
||||||
|
## Сверка манифестов с живыми списками
|
||||||
|
|
||||||
|
Манифест отстаёт, когда колонки добавляют руками или кодом. Перед провижинингом на рабочем
|
||||||
|
сайте и после ручных правок списков — сверка (только чтение):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./Compare-Manifests.ps1 `
|
||||||
|
-SiteUrl "https://tnsukz.sharepoint.com/sites/sed" `
|
||||||
|
-ClientId "<client-id>" `
|
||||||
|
-Tenant "tnsukz.onmicrosoft.com" `
|
||||||
|
-CertificatePath "<путь к .pfx>" -CertificatePassword "<пароль>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`-` есть в манифесте, нет в живом (создаст Provision-Lists.ps1), `+` есть в живом, нет в
|
||||||
|
манифесте (при пересоздании пропадёт — дописать в манифест), `~` отличаются тип, варианты или лукап.
|
||||||
|
|
||||||
|
## Добавить новый список на рабочий сайт
|
||||||
|
|
||||||
|
Полный прогон Provision-Lists.ps1 синхронизирует варианты Choice у ВСЕХ живых списков с
|
||||||
|
манифестами. Новый список добавляй точечно — `-Lists "Название"`: остальные списки не трогаются.
|
||||||
|
|
||||||
## Формат манифеста
|
## Формат манифеста
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
|
|||||||
Reference in New Issue
Block a user