Files
ct-forms-portal/provisioning/Export-ListSchema.ps1
T

259 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
Экспортирует схему реальных SP-списков в манифесты provisioning.json (источник
правды по колонкам форм портала ct-forms-portal).
.DESCRIPTION
Читает описание форм из sources.json и для каждой формы:
1) подключается к ИСХОДНОМУ сайту формы (sourceSite);
2) для каждого списка из формы читает пользовательские колонки
(кастомные, не системные) и их точные типы/варианты Choice/Lookup;
3) применяет правило переименования (renameSuffix, кроме renameExclude) —
и к самому имени списка, и к целевым спискам Lookup-колонок;
4) пишет манифест provisioning.json (мульти-список) в outFile формы.
Так схема снимается 1:1 с боевых списков (без ручных догадок по типам), а затем
Provision-Lists.ps1 воспроизводит её на целевом сайте (/sites/sed).
ВАЖНО: экспортируется только СХЕМА (списки + колонки). Данные справочников
(Поставщики, Курс Валют, Матрица согласования), настройка Syntex на библиотеке КП и
URL потоков Power Automate — НЕ входят в манифест и переносятся отдельно.
Вход — по сертификату «от имени приложения» (app-only, право Sites.Selected).
Приложению нужен доступ КО ВСЕМ исходным сайтам форм (напр. и /sites/kps, и /sites/sed).
.PARAMETER ClientId
Client Id приложения-провижинера (Sites.Selected).
.PARAMETER Tenant
Тенант, например tnsukz.onmicrosoft.com
.PARAMETER Thumbprint
Отпечаток сертификата в хранилище CurrentUser\My.
.PARAMETER SourcesPath
Путь к sources.json. По умолчанию — рядом со скриптом.
.PARAMETER Only
Экспортировать только указанные формы (по ключу form). По умолчанию — все.
.EXAMPLE
./Export-ListSchema.ps1 -ClientId "<id>" -Tenant "tnsukz.onmicrosoft.com" -Thumbprint "<tp>"
.EXAMPLE
# Только KPS
./Export-ListSchema.ps1 -ClientId "<id>" -Tenant "..." -Thumbprint "<tp>" -Only kps
#>
param(
[Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant,
[Parameter(Mandatory = $true)][string]$Thumbprint,
[string]$SourcesPath = "$PSScriptRoot/sources.json",
[string[]]$Only
)
$ErrorActionPreference = 'Stop'
# --- Переименование по правилу формы (суффикс, кроме исключений) ---
function Convert-ListTitle {
param([string]$Title, [string]$Suffix, [string[]]$Exclude)
if ([string]::IsNullOrEmpty($Suffix)) { return $Title }
if ($Exclude -and ($Exclude -contains $Title)) { return $Title }
if ($Title.EndsWith($Suffix)) { return $Title } # идемпотентно — не удваиваем суффикс
return "$Title$Suffix"
}
# --- SP-тип колонки → тип манифеста ---
# Возвращает hashtable колонки в формате манифеста или $null (колонку пропускаем).
function ConvertTo-ManifestColumn {
param($Field, [string]$Suffix, [string[]]$Exclude)
$t = [string]$Field.TypeAsString
$col = [ordered]@{
internalName = [string]$Field.InternalName
title = [string]$Field.Title
}
switch ($t) {
'Text' { $col.type = 'Text' }
'Note' { $col.type = 'Note' }
'Boolean' { $col.type = 'Boolean' }
'Number' {
$col.type = 'Number'
$dec = Get-DecimalsFromSchema -SchemaXml $Field.SchemaXml
if ($null -ne $dec) { $col.decimals = $dec }
}
'Currency' {
$col.type = 'Number'
$dec = Get-DecimalsFromSchema -SchemaXml $Field.SchemaXml
$col.decimals = if ($null -ne $dec) { $dec } else { 2 }
}
'DateTime' {
$col.type = 'DateTime'
# DisplayFormat: 0 = DateOnly, 1 = DateTime.
if ([int]$Field.DisplayFormat -eq 0) { $col.dateOnly = $true }
}
'Choice' {
$col.type = 'Choice'
$col.choices = @($Field.Choices)
if (Test-FillInChoice -SchemaXml $Field.SchemaXml) { $col.fillIn = $true }
}
'MultiChoice' {
$col.type = 'Choice'
$col.multiChoice = $true
$col.choices = @($Field.Choices)
if (Test-FillInChoice -SchemaXml $Field.SchemaXml) { $col.fillIn = $true }
}
'User' {
$col.type = 'User'
$col.userSelectionMode = if ([int]$Field.SelectionMode -eq 1) { 'PeopleAndGroups' } else { 'PeopleOnly' }
}
'UserMulti' {
$col.type = 'User'
$col.allowMultiple = $true
$col.userSelectionMode = if ([int]$Field.SelectionMode -eq 1) { 'PeopleAndGroups' } else { 'PeopleOnly' }
}
'Lookup' {
$col.type = 'Lookup'
$lk = Resolve-LookupTarget -Field $Field -Suffix $Suffix -Exclude $Exclude
if ($null -eq $lk) { return $null }
$col.lookupList = $lk.list
$col.lookupShowField = $lk.show
}
'LookupMulti' {
$col.type = 'Lookup'
$col.allowMultiple = $true
$lk = Resolve-LookupTarget -Field $Field -Suffix $Suffix -Exclude $Exclude
if ($null -eq $lk) { return $null }
$col.lookupList = $lk.list
$col.lookupShowField = $lk.show
}
default {
Write-Warning " [skip] $($Field.InternalName): неизвестный/несоздаваемый тип '$t'"
return $null
}
}
if ($Field.Required) { $col.required = $true }
return $col
}
# Decimals из SchemaXml числовой колонки (атрибут Decimals='N'), иначе $null.
function Get-DecimalsFromSchema {
param([string]$SchemaXml)
if ([string]::IsNullOrEmpty($SchemaXml)) { return $null }
$m = [regex]::Match($SchemaXml, "Decimals=['""](?<d>\d+)['""]")
if ($m.Success) { return [int]$m.Groups['d'].Value }
return $null
}
# Разрешён ли ввод своих значений в Choice (атрибут FillInChoice='TRUE').
function Test-FillInChoice {
param([string]$SchemaXml)
if ([string]::IsNullOrEmpty($SchemaXml)) { return $false }
return [regex]::IsMatch($SchemaXml, "FillInChoice=['""]TRUE['""]", [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
}
# Целевой список Lookup: GUID → Title (с применением rename), + show-поле.
function Resolve-LookupTarget {
param($Field, [string]$Suffix, [string[]]$Exclude)
$guid = [string]$Field.LookupList
if ([string]::IsNullOrEmpty($guid)) {
Write-Warning " [skip lookup] $($Field.InternalName): пустой LookupList (зависимый lookup?)"
return $null
}
try {
$target = Get-PnPList -Identity $guid -ErrorAction Stop
$rawTitle = [string]$target.Title
$show = if ($Field.LookupField) { [string]$Field.LookupField } else { 'Title' }
return @{ list = (Convert-ListTitle -Title $rawTitle -Suffix $Suffix -Exclude $Exclude); show = $show }
}
catch {
Write-Warning " [skip lookup] $($Field.InternalName): не разрешил целевой список $guid — $($_.Exception.Message)"
return $null
}
}
# Системные типы, которые не экспортируем даже если FromBaseType=false.
$SYSTEM_TYPES = @('Computed', 'Attachments', 'File', 'Counter', 'Guid', 'ContentTypeId', 'ModStat', 'WorkflowStatus', 'ThreadIndex')
# --- Основной проход ---
$sources = Get-Content -Path $SourcesPath -Raw -Encoding UTF8 | ConvertFrom-Json
$forms = $sources.forms
if ($Only) { $forms = $forms | Where-Object { $Only -contains $_.form } }
if (-not $forms) { throw "Формы для экспорта не найдены (проверь sources.json / -Only)" }
foreach ($f in $forms) {
Write-Host "`n=== Форма '$($f.form)' — экспорт с $($f.sourceSite) ===" -ForegroundColor Cyan
Connect-PnPOnline -Url $f.sourceSite -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
$suffix = if ($f.PSObject.Properties.Name -contains 'renameSuffix') { [string]$f.renameSuffix } else { '' }
$exclude = if ($f.PSObject.Properties.Name -contains 'renameExclude') { @($f.renameExclude) } else { @() }
$manifestLists = @()
foreach ($ls in $f.lists) {
$srcTitle = [string]$ls.title
$outTitle = Convert-ListTitle -Title $srcTitle -Suffix $suffix -Exclude $exclude
Write-Host " • список «$srcTitle» → «$outTitle»" -ForegroundColor White
$list = Get-PnPList -Identity $srcTitle -ErrorAction SilentlyContinue
if (-not $list) {
Write-Warning " [!] нет списка «$srcTitle» на $($f.sourceSite) — пропуск"
continue
}
$template = if ($ls.template) { [string]$ls.template } elseif ([int]$list.BaseType -eq 1) { 'DocumentLibrary' } else { 'GenericList' }
# Отсекаем: скрытые/базовые/readonly/системные типы, а также по имени —
# 'Title' (базовая, всегда есть), '_*' (системные: _ModelId, _ExtendedDescription …)
# и 'Prime*' (колонки модели Syntex — появляются при настройке распознавания, руками не создаём).
$fields = Get-PnPField -List $srcTitle | Where-Object {
(-not $_.Hidden) -and (-not $_.FromBaseType) -and (-not $_.ReadOnlyField) `
-and ($SYSTEM_TYPES -notcontains $_.TypeAsString) `
-and ($_.InternalName -ne 'Title') `
-and ($_.InternalName -notlike '_*') `
-and ($_.InternalName -notlike 'Prime*')
}
$columns = @()
foreach ($fld in $fields) {
$c = ConvertTo-ManifestColumn -Field $fld -Suffix $suffix -Exclude $exclude
if ($null -ne $c) {
$columns += $c
Write-Host " [+] $($c.internalName) [$($c.type)]" -ForegroundColor DarkGray
}
}
$entry = [ordered]@{
title = $outTitle
template = $template
columns = $columns
}
$manifestLists += $entry
}
$manifest = [ordered]@{
form = [string]$f.form
targetSite = [string]$f.targetSite
lists = $manifestLists
}
$outPath = Join-Path $PSScriptRoot $f.outFile
$outDir = Split-Path -Parent $outPath
if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
$json = $manifest | ConvertTo-Json -Depth 8
# ConvertTo-Json экранирует кириллицу в \uXXXX — декодируем ТОЛЬКО их (не трогая \" \\),
# чтобы манифест был читаемым UTF-8 и при этом остался валидным JSON.
$json = [regex]::Replace($json, '\\u(?<c>[0-9a-fA-F]{4})', {
param($m) [string][char][int]::Parse($m.Groups['c'].Value, [System.Globalization.NumberStyles]::HexNumber)
})
Set-Content -Path $outPath -Value $json -Encoding UTF8
Write-Host " → записан манифест $outPath" -ForegroundColor Green
Disconnect-PnPOnline
}
Write-Host "`nЭкспорт завершён." -ForegroundColor Cyan