259 lines
11 KiB
PowerShell
259 lines
11 KiB
PowerShell
<#
|
||
.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
|