Files
ct-forms-portal/provisioning/Provision-Lists.ps1
T
Тимур Городецкий b5d7efc749 feat: Provision-Lists.ps1 — вход по файлу сертификата .pfx (#1)
Рабочий app-only сертификат для /sites/sed лежит файлом с паролем, а не в
хранилище CurrentUser\My — скрипт умел только по отпечатку.

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

224 lines
11 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
Идемпотентно создаёт списки и библиотеки форм портала ct-forms-portal на целевом
сайте (/sites/sed) по манифестам provisioning.json.
.DESCRIPTION
Читает ВСЕ манифесты provisioning/manifests/*.provisioning.json (снятые
Export-ListSchema.ps1) и в ДВА прохода:
Проход A — создаёт все списки/библиотеки (если ещё нет) по всем манифестам.
Проход B — обеспечивает колонки на каждом списке.
Два прохода нужны, чтобы к моменту создания Lookup-колонки её целевой список уже
существовал (даже если он объявлен в другом манифесте — напр. общий «Поставщики»).
Литеральные имена: имя списка берётся как есть из манифеста (никаких конвенций
«— данные/— согласование», в отличие от request-portal). Поддержаны обычные списки
(GenericList) и библиотеки документов (DocumentLibrary).
Колонки создаются прямо НА СПИСКЕ (без общих столбцов сайта) — у форм CT/OID/KPS
имена колонок совпадают между списками, а Choice-наборы могут отличаться, поэтому
общий эталон сайта тут только мешал бы.
Повторный запуск безопасен: существующие списки/колонки не пересоздаются; для Choice
синхронизируются варианты.
Вход — по сертификату «от имени приложения» (app-only, Sites.Selected на /sites/sed).
.PARAMETER SiteUrl
Целевой сайт, например https://tnsukz.sharepoint.com/sites/sed
.PARAMETER ClientId
Client Id приложения-провижинера (Sites.Selected).
.PARAMETER Tenant
Тенант, например tnsukz.onmicrosoft.com
.PARAMETER Thumbprint
Отпечаток сертификата в хранилище CurrentUser\My. Либо он, либо CertificatePath.
.PARAMETER CertificatePath
Путь к файлу сертификата .pfx — если сертификат не установлен в хранилище.
.PARAMETER CertificatePassword
Пароль к .pfx из CertificatePath.
.PARAMETER ManifestsDir
Папка с манифестами. По умолчанию — provisioning/manifests рядом со скриптом.
.PARAMETER Only
Провижионить только манифесты указанных форм (по полю form). По умолчанию — все.
.PARAMETER Lists
Провижионить только списки с указанными названиями. Нужен для добавления нового списка
на рабочий сайт: полный прогон перезаписывает варианты Choice у живых списков
значениями из манифеста, а манифест мог отстать от живого списка.
.EXAMPLE
./Provision-Lists.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/sed" `
-ClientId "<id>" -Tenant "tnsukz.onmicrosoft.com" -Thumbprint "<tp>"
.EXAMPLE
./Provision-Lists.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/sed" `
-ClientId "<id>" -Tenant "tnsukz.onmicrosoft.com" -Thumbprint "<tp>" `
-Lists "Исключённые предложения", "Исключённые предложения ОИД"
#>
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,
[string[]]$Lists
)
$ErrorActionPreference = 'Stop'
function ConvertTo-XmlSafe {
param([string]$Value)
return $Value.Replace('&', '&amp;').Replace('<', '&lt;').Replace('>', '&gt;').Replace("'", '&apos;').Replace('"', '&quot;')
}
function Test-FieldExists {
param([string]$List, [string]$InternalName)
return $null -ne (Get-PnPField -List $List -Identity $InternalName -ErrorAction SilentlyContinue)
}
# Создаёт/обновляет колонку НА СПИСКЕ. Идемпотентно. Lookup/UserMulti/Number-с-decimals
# — через XML (иначе PnP-cmdlet не покрывает нюансы), остальное — обычным Add-PnPField.
function Add-ListColumn {
param([string]$List, $Col)
if (Test-FieldExists -List $List -InternalName $Col.internalName) {
if ($Col.type -eq 'Choice' -and $Col.choices) {
try {
$vals = @{ Choices = [string[]]$Col.choices }
if ($null -ne $Col.fillIn) { $vals.FillInChoice = [bool]$Col.fillIn }
Set-PnPField -List $List -Identity $Col.internalName -Values $vals | Out-Null
Write-Host " [~] варианты обновлены: $($Col.internalName)" -ForegroundColor DarkGray
}
catch { Write-Warning " [!] $($Col.internalName): не удалось обновить варианты — $($_.Exception.Message)" }
}
else { Write-Host " [=] есть: $($Col.internalName)" -ForegroundColor DarkGray }
return
}
try {
switch ($Col.type) {
'Text' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Text -AddToDefaultView | Out-Null }
'Note' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Note -AddToDefaultView | Out-Null }
'Boolean' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Boolean -AddToDefaultView | Out-Null }
'DateTime' {
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type DateTime -AddToDefaultView | Out-Null
if ($Col.dateOnly) { Set-PnPField -List $List -Identity $Col.internalName -Values @{ DisplayFormat = 0; FriendlyDisplayFormat = 1 } | Out-Null }
}
'Choice' {
$multi = [bool]$Col.multiChoice
$type = if ($multi) { 'MultiChoice' } else { 'Choice' }
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type $type -Choices $Col.choices -AddToDefaultView | Out-Null
if ($Col.fillIn) { Set-PnPField -List $List -Identity $Col.internalName -Values @{ FillInChoice = $true } | Out-Null }
}
'Number' {
if ($null -ne $Col.decimals) {
$xml = "<Field Type='Number' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' Decimals='$($Col.decimals)' />"
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
}
else {
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Number -AddToDefaultView | Out-Null
}
}
'User' {
if ($Col.allowMultiple) {
$xml = "<Field Type='UserMulti' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' Mult='TRUE' />"
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
}
else {
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type User -AddToDefaultView | Out-Null
}
if ($Col.userSelectionMode) {
$mode = if ($Col.userSelectionMode -eq 'PeopleAndGroups') { 1 } else { 0 }
Set-PnPField -List $List -Identity $Col.internalName -Values @{ SelectionMode = $mode } | Out-Null
}
}
'Lookup' {
$src = Get-PnPList -Identity $Col.lookupList -ErrorAction Stop
$guid = $src.Id.ToString()
$show = if ($Col.lookupShowField) { $Col.lookupShowField } else { 'Title' }
$multAttr = if ($Col.allowMultiple) { " Mult='TRUE'" } else { '' }
$ftype = if ($Col.allowMultiple) { 'LookupMulti' } else { 'Lookup' }
$xml = "<Field Type='$ftype' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' ShowField='$show' List='{$guid}'$multAttr />"
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
}
default { throw "Неизвестный тип колонки '$($Col.type)'" }
}
if ($Col.required -and $Col.type -ne 'Lookup') {
Set-PnPField -List $List -Identity $Col.internalName -Values @{ Required = $true } | Out-Null
}
Write-Host " [+] $($Col.internalName) — «$($Col.title)» [$($Col.type)]" -ForegroundColor Green
}
catch { Write-Warning " [!] $($Col.internalName): $($_.Exception.Message)" }
}
Write-Host "Подключение к $SiteUrl (app-only) ..." -ForegroundColor Cyan
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" }
$files = Get-ChildItem -Path $ManifestsDir -Filter '*.provisioning.json' -ErrorAction SilentlyContinue
if (-not $files) { throw "Манифесты *.provisioning.json не найдены в $ManifestsDir" }
$manifests = @()
foreach ($file in $files) {
$def = Get-Content -Path $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
if ($Only -and ($Only -notcontains $def.form)) { continue }
if ($Lists) {
$def.lists = @($def.lists | Where-Object { $Lists -contains $_.title })
if ($def.lists.Count -eq 0) { continue }
}
$manifests += $def
}
if (-not $manifests) { throw "Нет манифестов после фильтров -Only / -Lists" }
if ($Lists) {
$found = @($manifests | ForEach-Object { $_.lists } | ForEach-Object { $_.title })
$missing = @($Lists | Where-Object { $found -notcontains $_ })
if ($missing.Count -gt 0) { throw "Списков нет в манифестах: $($missing -join ', ')" }
}
# --- Проход A: создать все списки/библиотеки ---
Write-Host "`n=== Проход A: списки ===" -ForegroundColor Cyan
foreach ($def in $manifests) {
Write-Host "Форма '$($def.form)':" -ForegroundColor White
foreach ($ls in $def.lists) {
$tmpl = if ($ls.template -eq 'DocumentLibrary') { 'DocumentLibrary' } else { 'GenericList' }
if (Get-PnPList -Identity $ls.title -ErrorAction SilentlyContinue) {
Write-Host " [=] есть: «$($ls.title)»" -ForegroundColor DarkGray
}
else {
New-PnPList -Title $ls.title -Template $tmpl -EnableVersioning | Out-Null
Write-Host " [+] создан ($tmpl): «$($ls.title)»" -ForegroundColor Green
}
}
}
# --- Проход B: колонки ---
Write-Host "`n=== Проход B: колонки ===" -ForegroundColor Cyan
foreach ($def in $manifests) {
Write-Host "Форма '$($def.form)':" -ForegroundColor White
foreach ($ls in $def.lists) {
Write-Host " список «$($ls.title)»:" -ForegroundColor White
foreach ($col in $ls.columns) {
Add-ListColumn -List $ls.title -Col $col
}
}
}
Write-Host "`nГотово." -ForegroundColor Cyan
Disconnect-PnPOnline