b7250544ba
Без BOM Windows PowerShell 5.1 читает файл в системной кодировке и ломает кириллицу в строковых литералах (названия списков, ошибки). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
361 lines
20 KiB
PowerShell
361 lines
20 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
Идемпотентно создаёт списки данных форм портала заявок в SharePoint Online
|
||
по манифестам provisioning.json (единый источник правды по колонкам).
|
||
|
||
.DESCRIPTION
|
||
Обходит ВСЕ манифесты forms/**/provisioning.json (в т.ч. подпапки approval/) и для
|
||
каждого:
|
||
1) создаёт список (если его ещё нет);
|
||
2) при необходимости переименовывает колонку Title;
|
||
3) обеспечивает колонки.
|
||
|
||
Имена списков НУМЕРОВАННЫЕ: «<Форма> <N> — данные» / «<Форма> <N> — согласование».
|
||
Номер собирает сам скрипт из имени в манифесте (см. Get-NumberedListTitle) — в
|
||
манифестах имена остаются без номера, чтобы номер не дублировался по 14 манифестам
|
||
и ссылкам lookupList. Нумерация нужна под архивацию: когда список данных упирается
|
||
в порог элементов, рядом создаётся следующая пара с номером N+1.
|
||
|
||
Перед основным проходом идёт разовая МИГРАЦИЯ имён (только при -ListNumber 1):
|
||
существующие списки со старым безномерным именем переименовываются в «… 1 — …».
|
||
Переименовывается только Title — server-relative URL списка при этом НЕ меняется,
|
||
поэтому ссылки на список (request_type.list_id в approval-route) остаются валидными.
|
||
|
||
Колонки — через СТОЛБЦЫ САЙТА (эталоны):
|
||
- не-Lookup колонка сначала создаётся как столбец сайта (группа «Портал заявок»),
|
||
один раз на весь сайт, затем ПОДКЛЮЧАЕТСЯ к списку по ссылке. Так одинаковые
|
||
колонки (Status, Phase, StepOrder …) определены в одном месте и единообразны;
|
||
менять их (напр. варианты Choice) можно в эталоне. Choice при повторе объединяет
|
||
варианты (union) — наборы разных форм не затирают друг друга.
|
||
- Lookup остаётся столбцом СПИСКА: эталон-lookup привязан к одному списку, а у нас
|
||
Ticket у каждого списка согласования ссылается на свой список данных.
|
||
|
||
Повторный запуск безопасен (идемпотентно): существующие эталоны/колонки не
|
||
пересоздаются. Имена колонок (internalName) — как в манифестах, к ним обращается код.
|
||
|
||
Вход — по сертификату «от имени приложения» (app-only, право Sites.Selected).
|
||
Сертификат берётся из личного хранилища по отпечатку (Thumbprint).
|
||
|
||
.PARAMETER SiteUrl
|
||
URL сайта, например https://tnsukz.sharepoint.com/sites/requests
|
||
|
||
.PARAMETER ClientId
|
||
Client Id приложения-провижинера (Sites.Selected).
|
||
|
||
.PARAMETER Tenant
|
||
Тенант, например tnsukz.onmicrosoft.com
|
||
|
||
.PARAMETER Thumbprint
|
||
Отпечаток сертификата в хранилище CurrentUser\My.
|
||
|
||
.PARAMETER FormsRoot
|
||
Папка с формами. По умолчанию — src/webparts/requestPortal/forms.
|
||
|
||
.PARAMETER ListNumber
|
||
Номер пары списков. 1 — текущие (рабочие) списки; 2, 3 … — следующие пары под
|
||
архивацию. Миграция безномерных имён выполняется только при 1.
|
||
|
||
.PARAMETER SkipRename
|
||
Не выполнять миграцию имён существующих списков (только создание/колонки).
|
||
|
||
.EXAMPLE
|
||
./Provision-Lists.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
|
||
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
|
||
-Thumbprint "AABBCC...."
|
||
|
||
.EXAMPLE
|
||
# Следующая пара списков под архивацию, без миграции имён.
|
||
./Provision-Lists.ps1 -SiteUrl "..." -ClientId "..." -Tenant "..." -Thumbprint "..." `
|
||
-ListNumber 2
|
||
#>
|
||
param(
|
||
[Parameter(Mandatory = $true)][string]$SiteUrl,
|
||
[Parameter(Mandatory = $true)][string]$ClientId,
|
||
[Parameter(Mandatory = $true)][string]$Tenant,
|
||
[Parameter(Mandatory = $true)][string]$Thumbprint,
|
||
[string]$FormsRoot = "$PSScriptRoot/../src/webparts/requestPortal/forms",
|
||
[ValidateRange(1, 999)][int]$ListNumber = 1,
|
||
[switch]$SkipRename
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
|
||
# Экранирование значений, попадающих в XML полей (Lookup/Number с точностью).
|
||
function ConvertTo-XmlSafe {
|
||
param([string]$Value)
|
||
return $Value.Replace('&', '&').Replace('<', '<').Replace('>', '>').Replace("'", ''').Replace('"', '"')
|
||
}
|
||
|
||
# Имя списка по конвенции: «<Форма> — данные» / «<Форма> — согласование».
|
||
$LIST_TITLE_PATTERN = '^(?<base>.+?)\s*—\s*(?<suffix>данные|согласование)\s*$'
|
||
|
||
# Имя в манифесте → нумерованное «<Форма> <N> — <суффикс>».
|
||
#
|
||
# Номер ставится ПЕРЕД суффиксом, и это не косметика: и портал
|
||
# (features/myRequests/myRequests.service.ts), и админка маршрутов
|
||
# (approval-route/frontend/.../requestManageService.ts) выводят имя списка
|
||
# согласования из имени списка данных регексом /\s*—\s*данные\s*$/, привязанным к
|
||
# КОНЦУ строки. «… — данные 1» этот регекс не матчит, и список согласования просто
|
||
# перестаёт находиться.
|
||
#
|
||
# Идемпотентно: уже нумерованное имя не получает второй номер, а перенумеровывается.
|
||
function Get-NumberedListTitle {
|
||
param([string]$Title, [int]$Number)
|
||
|
||
$m = [regex]::Match($Title, $LIST_TITLE_PATTERN)
|
||
if (-not $m.Success) {
|
||
throw "Имя списка «$Title» не по конвенции «<Форма> — данные|согласование» — номер поставить некуда"
|
||
}
|
||
$base = $m.Groups['base'].Value -replace '\s+\d+$', ''
|
||
return "$base $Number — $($m.Groups['suffix'].Value)"
|
||
}
|
||
|
||
# Есть ли колонка НА СПИСКЕ (list-scope).
|
||
function Test-FieldExists {
|
||
param([string]$List, [string]$InternalName)
|
||
return $null -ne (Get-PnPField -List $List -Identity $InternalName -ErrorAction SilentlyContinue)
|
||
}
|
||
|
||
# Есть ли СТОЛБЕЦ САЙТА (site-scope, галерея столбцов сайта).
|
||
function Test-SiteFieldExists {
|
||
param([string]$InternalName)
|
||
return $null -ne (Get-PnPField -Identity $InternalName -ErrorAction SilentlyContinue)
|
||
}
|
||
|
||
$SITE_COLUMN_GROUP = 'Портал заявок'
|
||
|
||
# Типы, которые НЕ делаем эталоном (столбцом сайта), а создаём столбцом списка:
|
||
# - Lookup (эталон привязан к одному списку, а Ticket у каждого свой);
|
||
# - Number с decimals и UserMulti (создаются через XML; web-level XML в PnP падает
|
||
# NRE, и это одноразовые поля конкретной формы — переиспользовать нечего).
|
||
function Test-NeedsListColumn {
|
||
param($Col)
|
||
return ($Col.type -eq 'Lookup') `
|
||
-or ($Col.type -eq 'Number' -and $null -ne $Col.decimals) `
|
||
-or ($Col.type -eq 'User' -and $Col.allowMultiple)
|
||
}
|
||
|
||
# Создаёт/обновляет СТОЛБЕЦ САЙТА (эталон) — один раз на весь сайт. Только простые
|
||
# типы (без XML). Для Choice при повторе объединяет варианты (union), чтобы наборы
|
||
# разных форм не затирали друг друга.
|
||
function Ensure-SiteColumn {
|
||
param($Col)
|
||
|
||
if (Test-SiteFieldExists -InternalName $Col.internalName) {
|
||
if ($Col.type -eq 'Choice' -and $Col.choices) {
|
||
$existing = (Get-PnPField -Identity $Col.internalName).Choices
|
||
$merged = @($existing) + @($Col.choices) | Select-Object -Unique
|
||
Set-PnPField -Identity $Col.internalName -Values @{ Choices = [string[]]$merged } | Out-Null
|
||
}
|
||
return
|
||
}
|
||
|
||
$g = $SITE_COLUMN_GROUP
|
||
switch ($Col.type) {
|
||
'Text' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Text -Group $g | Out-Null }
|
||
'Note' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Note -Group $g | Out-Null }
|
||
'DateTime' {
|
||
Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type DateTime -Group $g | Out-Null
|
||
if ($Col.dateOnly) {
|
||
Set-PnPField -Identity $Col.internalName -Values @{ DisplayFormat = 0; FriendlyDisplayFormat = 1 } | Out-Null
|
||
}
|
||
}
|
||
'Boolean' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Boolean -Group $g | Out-Null }
|
||
'User' {
|
||
Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type User -Group $g | Out-Null
|
||
if ($Col.userSelectionMode) {
|
||
$mode = if ($Col.userSelectionMode -eq 'PeopleAndGroups') { 1 } else { 0 }
|
||
Set-PnPField -Identity $Col.internalName -Values @{ SelectionMode = $mode } | Out-Null
|
||
}
|
||
}
|
||
'Choice' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -Group $g | Out-Null }
|
||
'Number' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Number -Group $g | Out-Null }
|
||
default { throw "Неизвестный тип столбца сайта '$($Col.type)'" }
|
||
}
|
||
Write-Host " [+эталон] $($Col.internalName) — «$($Col.title)» [$($Col.type)]" -ForegroundColor Green
|
||
}
|
||
|
||
# Создаёт колонку прямо НА СПИСКЕ (для Lookup / XML-типов). Идемпотентно.
|
||
function Add-ListColumn {
|
||
param([string]$List, $Col)
|
||
|
||
if (Test-FieldExists -List $List -InternalName $Col.internalName) {
|
||
if ($Col.type -eq 'Choice' -and $Col.choices) {
|
||
try {
|
||
Set-PnPField -List $List -Identity $Col.internalName -Values @{ Choices = [string[]]$Col.choices } | 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 }
|
||
'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 }
|
||
}
|
||
'Boolean' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Boolean -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
|
||
}
|
||
}
|
||
'Choice' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -AddToDefaultView | 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
|
||
}
|
||
}
|
||
'Lookup' {
|
||
# lookupList в манифесте — безномерное имя, целевой список тоже нумерован.
|
||
# Кросс-формные lookup'ы (WorkplaceSetup.SourceHire → «Прием на работу … — данные»)
|
||
# при этом предполагают ОДИН И ТОТ ЖЕ номер у обеих форм — при появлении
|
||
# реальных архивных пар это место придётся пересмотреть.
|
||
$lookupTitle = Get-NumberedListTitle -Title $Col.lookupList -Number $ListNumber
|
||
$src = Get-PnPList -Identity $lookupTitle -ErrorAction Stop
|
||
$guid = $src.Id.ToString()
|
||
$show = if ($Col.lookupShowField) { $Col.lookupShowField } else { 'Title' }
|
||
$xml = "<Field Type='Lookup' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' ShowField='$show' List='{$guid}' />"
|
||
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
|
||
}
|
||
default { throw "Неизвестный тип колонки '$($Col.type)'" }
|
||
}
|
||
if ($Col.required) { 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)" }
|
||
}
|
||
|
||
# Обеспечивает колонку НА СПИСКЕ: простые типы — из эталона (столбец сайта →
|
||
# подключить), Lookup/XML-типы — обычным столбцом списка.
|
||
function Add-ColumnToList {
|
||
param([string]$List, $Col)
|
||
|
||
if (Test-NeedsListColumn -Col $Col) {
|
||
Add-ListColumn -List $List -Col $Col
|
||
return
|
||
}
|
||
|
||
# 1) эталон на сайте (создать/обновить)
|
||
try {
|
||
Ensure-SiteColumn -Col $Col
|
||
}
|
||
catch {
|
||
Write-Warning " [!] эталон $($Col.internalName): $($_.Exception.Message)"
|
||
return
|
||
}
|
||
|
||
# 2) подключить эталон к списку (если ещё не подключён). Без -AddToDefaultView:
|
||
# с параметром -Field он несовместим (другой набор параметров cmdlet).
|
||
if (Test-FieldExists -List $List -InternalName $Col.internalName) {
|
||
Write-Host " [=] уже на списке: $($Col.internalName)" -ForegroundColor DarkGray
|
||
}
|
||
else {
|
||
try {
|
||
Add-PnPField -List $List -Field $Col.internalName | Out-Null
|
||
Write-Host " [+] подключён эталон: $($Col.internalName)" -ForegroundColor Green
|
||
}
|
||
catch {
|
||
Write-Warning " [!] подключение $($Col.internalName): $($_.Exception.Message)"
|
||
}
|
||
}
|
||
|
||
# 3) обязательность — настройка уровня списка (у поля может отличаться по спискам)
|
||
if ($Col.required) {
|
||
try { Set-PnPField -List $List -Identity $Col.internalName -Values @{ Required = $true } | Out-Null } catch {}
|
||
}
|
||
}
|
||
|
||
Write-Host "Подключение к $SiteUrl (app-only) ..." -ForegroundColor Cyan
|
||
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
|
||
|
||
$manifests = Get-ChildItem -Path $FormsRoot -Recurse -Filter 'provisioning.json'
|
||
if (-not $manifests) {
|
||
Write-Warning "Манифесты provisioning.json не найдены в $FormsRoot"
|
||
Disconnect-PnPOnline
|
||
return
|
||
}
|
||
|
||
# Проход 0: миграция безномерных имён на «… 1 — …». Отдельным проходом ДО основного —
|
||
# так порядок обхода манифестов не важен: к моменту разрешения Lookup'а (по имени
|
||
# списка-источника) все списки уже переименованы.
|
||
#
|
||
# Переименовывается только Title. URL списка при переименовании Title в SharePoint не
|
||
# меняется, поэтому request_type.list_id (server-relative URL) в approval-route
|
||
# трогать не нужно.
|
||
if ($ListNumber -eq 1 -and -not $SkipRename) {
|
||
Write-Host "`n=== Миграция имён списков на «… 1 — …» ===" -ForegroundColor Cyan
|
||
foreach ($manifest in $manifests) {
|
||
$def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
|
||
$legacyTitle = $def.dataListTitle
|
||
$targetTitle = Get-NumberedListTitle -Title $legacyTitle -Number 1
|
||
|
||
if (Get-PnPList -Identity $targetTitle -ErrorAction SilentlyContinue) {
|
||
Write-Host " [=] уже нумерован: «$targetTitle»" -ForegroundColor DarkGray
|
||
continue
|
||
}
|
||
if (-not (Get-PnPList -Identity $legacyTitle -ErrorAction SilentlyContinue)) {
|
||
Write-Host " [ ] нет списка «$legacyTitle» — создастся ниже как «$targetTitle»" -ForegroundColor DarkGray
|
||
continue
|
||
}
|
||
try {
|
||
Set-PnPList -Identity $legacyTitle -Title $targetTitle | Out-Null
|
||
Write-Host " [~] «$legacyTitle» → «$targetTitle»" -ForegroundColor Yellow
|
||
}
|
||
catch {
|
||
Write-Warning " [!] не удалось переименовать «$legacyTitle»: $($_.Exception.Message)"
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach ($manifest in $manifests) {
|
||
$def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
|
||
$listTitle = Get-NumberedListTitle -Title $def.dataListTitle -Number $ListNumber
|
||
Write-Host "`n=== Форма: $($manifest.Directory.Name) → список «$listTitle» ===" -ForegroundColor Cyan
|
||
|
||
# 1) Список (идемпотентно)
|
||
if (-not (Get-PnPList -Identity $listTitle -ErrorAction SilentlyContinue)) {
|
||
New-PnPList -Title $listTitle -Template GenericList -EnableVersioning | Out-Null
|
||
Write-Host " [+] создан список" -ForegroundColor Green
|
||
}
|
||
else {
|
||
Write-Host " [=] список уже существует" -ForegroundColor DarkGray
|
||
}
|
||
|
||
# 2) Переименование Title (если задано)
|
||
if ($def.titleColumnName) {
|
||
try {
|
||
Set-PnPField -List $listTitle -Identity 'Title' -Values @{ Title = $def.titleColumnName } | Out-Null
|
||
Write-Host " [~] Title → «$($def.titleColumnName)»" -ForegroundColor DarkGray
|
||
}
|
||
catch {
|
||
Write-Warning " [!] не удалось переименовать Title: $($_.Exception.Message)"
|
||
}
|
||
}
|
||
|
||
# 3) Колонки (идемпотентно): не-Lookup — через эталоны (столбцы сайта), Lookup — на список.
|
||
foreach ($col in $def.columns) {
|
||
Add-ColumnToList -List $listTitle -Col $col
|
||
}
|
||
}
|
||
|
||
Write-Host "`nГотово." -ForegroundColor Cyan
|
||
Disconnect-PnPOnline
|