<# .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 "" -Tenant "tnsukz.onmicrosoft.com" -Thumbprint "" .EXAMPLE # Только KPS ./Export-ListSchema.ps1 -ClientId "" -Tenant "..." -Thumbprint "" -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+)['""]") 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(?[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