diff --git a/provisioning/Compare-Manifests.ps1 b/provisioning/Compare-Manifests.ps1 new file mode 100644 index 0000000..c6330a0 --- /dev/null +++ b/provisioning/Compare-Manifests.ps1 @@ -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 "" -Tenant "tnsukz.onmicrosoft.com" -CertificatePath "" -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 diff --git a/provisioning/README.md b/provisioning/README.md index ad5342d..a79eb60 100644 --- a/provisioning/README.md +++ b/provisioning/README.md @@ -103,6 +103,27 @@ cd provisioning Лог: `[+]` создано, `[=]` уже было, `[~]` обновлено, `[!]` предупреждение. +## Сверка манифестов с живыми списками + +Манифест отстаёт, когда колонки добавляют руками или кодом. Перед провижинингом на рабочем +сайте и после ручных правок списков — сверка (только чтение): + +```powershell +./Compare-Manifests.ps1 ` + -SiteUrl "https://tnsukz.sharepoint.com/sites/sed" ` + -ClientId "" ` + -Tenant "tnsukz.onmicrosoft.com" ` + -CertificatePath "<путь к .pfx>" -CertificatePassword "<пароль>" +``` + +`-` есть в манифесте, нет в живом (создаст Provision-Lists.ps1), `+` есть в живом, нет в +манифесте (при пересоздании пропадёт — дописать в манифест), `~` отличаются тип, варианты или лукап. + +## Добавить новый список на рабочий сайт + +Полный прогон Provision-Lists.ps1 синхронизирует варианты Choice у ВСЕХ живых списков с +манифестами. Новый список добавляй точечно — `-Lists "Название"`: остальные списки не трогаются. + ## Формат манифеста ```jsonc