Files
elixforms-schema-bulk-updater/.agents/skills/elixforms-schema-bulk-updater/scripts/Invoke-ElixFormsSchemaBulkUpdate.ps1
T
2026-08-24 12:22:32 +02:00

713 lines
24 KiB
PowerShell

<#
.SYNOPSIS
Interactively replaces one field across confirmed ElixForms metadata rows.
.DESCRIPTION
Loads a schema, prompts for optional filters, previews every matching row, creates
a timestamped JSON backup, and submits one multipart save per row. The cookie is
read through a hidden prompt and is never persisted.
.PARAMETER BaseUri
Base URI of the ElixForms metadata API.
.PARAMETER BackupDirectory
Directory for pre-write search-response backups.
.PARAMETER PageSize
Number of rows requested per search page. All reported pages are retrieved.
.PARAMETER RunWhenDotSourced
Runs the interactive workflow even when the script is dot-sourced. Intended for
debuggers such as the VS Code PowerShell debugger that dot-source launch scripts.
#>
[CmdletBinding()]
param(
[Parameter()]
[ValidateNotNullOrEmpty()]
[string] $BaseUri = 'https://console-unipr.elixforms.it/AJSRV/metadata',
[Parameter()]
[ValidateNotNullOrEmpty()]
[string] $BackupDirectory = (Join-Path (Get-Location) 'backups'),
[Parameter()]
[ValidateRange(1, 1000)]
[int] $PageSize = 80,
[Parameter()]
[switch] $RunWhenDotSourced
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Net.Http
function ConvertTo-QueryString {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[System.Collections.IDictionary] $Parameters
)
$pairs = foreach ($entry in $Parameters.GetEnumerator()) {
$encodedName = [Uri]::EscapeDataString([string] $entry.Key)
$encodedValue = [Uri]::EscapeDataString([string] $entry.Value)
'{0}={1}' -f $encodedName, $encodedValue
}
return ($pairs -join '&')
}
function New-ElixFormsUri {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $BaseUri,
[Parameter(Mandatory)]
[string] $Route,
[Parameter(Mandatory)]
[System.Collections.IDictionary] $Query
)
$root = $BaseUri.TrimEnd('/')
return [Uri] ('{0}/{1}?{2}' -f $root, $Route, (ConvertTo-QueryString -Parameters $Query))
}
function ConvertFrom-SecureStringToPlainText {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[Security.SecureString] $SecureValue
)
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureValue)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
}
function Invoke-ElixFormsRequest {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[Net.Http.HttpClient] $Client,
[Parameter(Mandatory)]
[ValidateSet('GET', 'POST')]
[string] $Method,
[Parameter(Mandatory)]
[Uri] $Uri,
[Parameter(Mandatory)]
[string] $Cookie
)
$httpMethod = if ($Method -eq 'GET') { [Net.Http.HttpMethod]::Get } else { [Net.Http.HttpMethod]::Post }
$request = [Net.Http.HttpRequestMessage]::new($httpMethod, $Uri)
$null = $request.Headers.TryAddWithoutValidation('Accept', '*/*')
$null = $request.Headers.TryAddWithoutValidation('Cookie', $Cookie)
$null = $request.Headers.TryAddWithoutValidation('X-Requested-With', 'XMLHttpRequest')
$origin = '{0}://{1}' -f $Uri.Scheme, $Uri.Authority
$null = $request.Headers.TryAddWithoutValidation('Origin', $origin)
$request.Headers.Referrer = [Uri] ($origin + '/ajconsole/')
try {
$response = $Client.SendAsync($request).GetAwaiter().GetResult()
try {
$responseBytes = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
$responseText = [Text.Encoding]::UTF8.GetString($responseBytes)
if (-not $response.IsSuccessStatusCode) {
$previewLength = [Math]::Min(500, $responseText.Length)
$preview = $responseText.Substring(0, $previewLength)
throw 'HTTP {0} ({1}) from {2}: {3}' -f [int] $response.StatusCode, $response.ReasonPhrase, $Uri, $preview
}
try {
$json = $responseText | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw 'The response from {0} was not valid JSON: {1}' -f $Uri, $_.Exception.Message
}
return [pscustomobject]@{
Json = $json
Raw = $responseText
StatusCode = [int] $response.StatusCode
}
}
catch {
throw 'Failed to read response from {0}: {1}' -f $Uri, $_.Exception.Message
}
finally {
$response.Dispose()
}
}
catch {
throw 'Request to {0} failed: {1}' -f $Uri, $_.Exception.Message
}
finally {
$request.Dispose()
}
}
function New-MultipartFieldBody {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidatePattern('^[A-Za-z0-9_]+$')]
[string] $Name,
[Parameter(Mandatory)]
[AllowEmptyString()]
[string] $Value
)
$boundary = '---' + [Guid]::NewGuid().ToString('N').Substring(0, 24)
$newLine = "`r`n"
$body = '--{0}{1}Content-Disposition: form-data; name="{2}"{1}{1}{3}{1}--{0}--{1}' -f $boundary, $newLine, $Name, $Value
return [pscustomobject]@{
Boundary = $boundary
ContentType = 'multipart/form-data; boundary={0}' -f $boundary
Body = $body
}
}
function Invoke-ElixFormsSaveRequest {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[Uri] $Uri,
[Parameter(Mandatory)]
[string] $Cookie,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $ContentType,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $Body
)
$headers = @{}
$headers.Add('Accept', '*/*')
$headers.Add('Accept-Encoding', 'gzip, deflate, br, zstd')
$headers.Add('Cookie', $Cookie)
$headers.Add('x-requested-with', 'XMLHttpRequest')
$headers.Add('Content-Type', $ContentType)
try {
$response = Invoke-RestMethod -Uri $Uri -Method POST -Headers $headers -ContentType $ContentType -Body $Body -ErrorAction Stop
return [pscustomobject]@{
Json = $response
}
}
catch {
throw 'Request to {0} failed: {1}' -f $Uri, $_.Exception.Message
}
}
function Read-PositiveInteger {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Prompt
)
while ($true) {
$text = Read-Host $Prompt
$value = 0
if ([int]::TryParse($text, [ref] $value) -and $value -gt 0) {
return $value
}
Write-Warning 'Enter a positive integer.'
}
}
function Read-YesNo {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Prompt
)
$answer = (Read-Host ($Prompt + ' [y/N]')).Trim().ToLowerInvariant()
return $answer -in @('y', 'yes', 's', 'si', 'sì')
}
function Get-OrderedSchemaFields {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object] $Schema
)
$fields = [Collections.Generic.List[object]]::new()
foreach ($section in @($Schema.sections | Sort-Object -Property orderKey)) {
foreach ($field in @($section.fields | Sort-Object -Property orderKey)) {
$fields.Add([pscustomobject]@{
SectionKey = [string] $section.key
SectionTitle = [string] $section.title
Key = [string] $field.key
Title = [string] $field.title
Type = [string] $field.type
OrderKey = [string] $field.orderKey
})
}
}
return $fields.ToArray()
}
function Show-Schema {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object] $Schema
)
Write-Host ''
Write-Host ('Schema ID: {0}' -f $Schema.id)
Write-Host ('Title: {0}' -f $Schema.title)
Write-Host 'Sections and fields:'
foreach ($section in @($Schema.sections | Sort-Object -Property orderKey)) {
Write-Host (' {0} - {1}' -f $section.key, $section.title)
foreach ($field in @($section.fields | Sort-Object -Property orderKey)) {
Write-Host (' {0,-12} {1} ({2})' -f $field.key, $field.title, $field.type)
}
}
Write-Host ''
}
function Get-ElixFormsSearchResult {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[Net.Http.HttpClient] $Client,
[Parameter(Mandatory)]
[string] $BaseUri,
[Parameter(Mandatory)]
[string] $Cookie,
[Parameter(Mandatory)]
[int] $SchemaId,
[Parameter(Mandatory)]
[System.Collections.IDictionary] $Filters,
[Parameter(Mandatory)]
[int] $PageSize
)
$pages = [Collections.Generic.List[object]]::new()
$rows = [Collections.Generic.List[object]]::new()
$columns = $null
$expectedPages = 1
for ($page = 1; $page -le $expectedPages; $page++) {
$query = [ordered]@{ 'SEARCH_IN[]' = $SchemaId }
foreach ($entry in $Filters.GetEnumerator()) {
$query['S_{0}_NEW_{1}' -f $SchemaId, $entry.Key] = $entry.Value
}
$query.PAGE = $page
$query.AJL = 'it'
$query.PAGESIZE = $PageSize
$query.SECURE = 'true'
$uri = New-ElixFormsUri -BaseUri $BaseUri -Route 'search' -Query $query
Write-Host ('Searching page {0}...' -f $page)
$result = Invoke-ElixFormsRequest -Client $Client -Method GET -Uri $uri -Cookie $Cookie
$pageObject = $result.Json
if ($page -eq 1) {
$columns = @($pageObject.columns)
$expectedPages = [Math]::Max(1, [int] $pageObject.numPages)
}
else {
$pageColumnKeys = @($pageObject.columns | ForEach-Object { [string] $_.key })
$firstColumnKeys = @($columns | ForEach-Object { [string] $_.key })
if (($pageColumnKeys -join "`n") -ne ($firstColumnKeys -join "`n")) {
throw 'Search column order changed between pages; refusing to merge ambiguous rows.'
}
}
$pages.Add($pageObject)
foreach ($row in @($pageObject.data)) {
if (@($row).Count -ne $columns.Count) {
throw 'A search row has {0} values but the response defines {1} columns.' -f @($row).Count, $columns.Count
}
$rows.Add(@($row))
}
}
return [pscustomobject]@{
Columns = @($columns)
Rows = $rows.ToArray()
Pages = $pages.ToArray()
TotalRecords = if ($pages.Count -gt 0) { [int] $pages[0].totalRecords } else { 0 }
}
}
function ConvertTo-PreviewText {
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
[AllowEmptyString()]
[object] $Value,
[Parameter()]
[int] $MaximumLength = 60
)
if ($null -eq $Value) {
return ''
}
$text = ([string] $Value) -replace '\s+', ' '
if ($text.Length -le $MaximumLength) {
return $text
}
return $text.Substring(0, $MaximumLength - 3) + '...'
}
function Show-SearchResults {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object[]] $Columns,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]] $Rows
)
if ($Rows.Count -eq 0) {
Write-Host 'No matching rows.'
return
}
$displayRows = for ($rowIndex = 0; $rowIndex -lt $Rows.Count; $rowIndex++) {
$properties = [ordered]@{ '#' = $rowIndex + 1 }
for ($columnIndex = 0; $columnIndex -lt $Columns.Count; $columnIndex++) {
$column = $Columns[$columnIndex]
$label = '{0} [{1}]' -f $column.value, $column.key
$properties[$label] = ConvertTo-PreviewText -Value $Rows[$rowIndex][$columnIndex]
}
[pscustomobject] $properties
}
Write-Host ''
Write-Host ('Matching rows: {0} (long values are truncated only in this preview)' -f $Rows.Count)
$displayRows | Format-Table -AutoSize | Out-Host
}
function Save-SearchBackup {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $BackupDirectory,
[Parameter(Mandatory)]
[string] $BaseUri,
[Parameter(Mandatory)]
[int] $SchemaId,
[Parameter(Mandatory)]
[System.Collections.IDictionary] $Filters,
[Parameter(Mandatory)]
[int] $PageSize,
[Parameter(Mandatory)]
[object[]] $Pages
)
$resolvedDirectory = [IO.Path]::GetFullPath($BackupDirectory)
$null = New-Item -ItemType Directory -Path $resolvedDirectory -Force
$timestamp = Get-Date -Format 'yyyyMMddHHmmss'
$baseName = 'elixforms-schema-{0}-search-{1}' -f $SchemaId, $timestamp
$path = Join-Path $resolvedDirectory ($baseName + '.json')
$suffix = 1
while (Test-Path -LiteralPath $path) {
$path = Join-Path $resolvedDirectory ('{0}-{1}.json' -f $baseName, $suffix)
$suffix++
}
$filterCopy = [ordered]@{}
foreach ($entry in $Filters.GetEnumerator()) {
$filterCopy[[string] $entry.Key] = [string] $entry.Value
}
$backup = [ordered]@{
backedUpAt = (Get-Date).ToString('o')
baseUri = $BaseUri
schemaId = $SchemaId
pageSize = $PageSize
filters = $filterCopy
pages = $Pages
}
$json = $backup | ConvertTo-Json -Depth 100
[IO.File]::WriteAllText($path, $json, [Text.UTF8Encoding]::new($false))
return $path
}
function Get-ColumnIndex {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object[]] $Columns,
[Parameter(Mandatory)]
[string] $Key
)
for ($index = 0; $index -lt $Columns.Count; $index++) {
if ([string] $Columns[$index].key -ceq $Key) {
return $index
}
}
return -1
}
function Select-UpdateField {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object[]] $Fields
)
Write-Host 'Fields available for update:'
for ($index = 0; $index -lt $Fields.Count; $index++) {
Write-Host (' {0,2}. {1,-12} {2} ({3})' -f ($index + 1), $Fields[$index].Key, $Fields[$index].Title, $Fields[$index].Type)
}
while ($true) {
$choice = (Read-Host 'Field number or key (empty to exit)').Trim()
if ($choice.Length -eq 0) {
return $null
}
$number = 0
if ([int]::TryParse($choice, [ref] $number) -and $number -ge 1 -and $number -le $Fields.Count) {
return $Fields[$number - 1]
}
$found = @($Fields | Where-Object { $_.Key -ieq $choice })
if ($found.Count -eq 1) {
return $found[0]
}
Write-Warning 'Select one of the listed field numbers or keys.'
}
}
function Invoke-ElixFormsMetadataBulkUpdate {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $BaseUri,
[Parameter(Mandatory)]
[string] $BackupDirectory,
[Parameter(Mandatory)]
[int] $PageSize
)
Write-Host 'Log in to ElixForms in your browser, then copy the complete Cookie request-header value.'
$secureCookie = Read-Host 'Cookie header value (input hidden)' -AsSecureString
if ($secureCookie.Length -eq 0) {
Write-Warning 'No cookie supplied. Exiting.'
return
}
$cookie = ConvertFrom-SecureStringToPlainText -SecureValue $secureCookie
$secureCookie.Dispose()
$client = $null
try {
$handler = [Net.Http.HttpClientHandler]::new()
$handler.AutomaticDecompression = [Net.DecompressionMethods]::GZip -bor [Net.DecompressionMethods]::Deflate
$client = [Net.Http.HttpClient]::new($handler, $true)
$client.Timeout = [TimeSpan]::FromMinutes(2)
$schemaId = Read-PositiveInteger -Prompt 'Schema ID'
$listQuery = [ordered]@{ AJL = 'it'; ID = $schemaId; ACL = 'true' }
$listUri = New-ElixFormsUri -BaseUri $BaseUri -Route 'list' -Query $listQuery
Write-Host 'Loading schema...'
$listResult = Invoke-ElixFormsRequest -Client $client -Method GET -Uri $listUri -Cookie $cookie
if ($listResult.Json -isnot [PSCustomObject] -or -not $listResult.Json.PSObject.Properties.Name.Contains('objectList')) {
throw 'Schema {0} was not found in the list response. Message: {1}' -f $schemaId, $listResult.Json.message
}
$schemas = @($listResult.Json.objectList)
$schema = @($schemas | Where-Object { [int] $_.id -eq $schemaId }) | Select-Object -First 1
if ($null -eq $schema) {
throw 'Schema {0} was not present in the list response.' -f $schemaId
}
Show-Schema -Schema $schema
if (-not (Read-YesNo -Prompt 'Is this the intended schema?')) {
Write-Host 'Cancelled before search.'
return
}
$fields = @(Get-OrderedSchemaFields -Schema $schema)
if ($fields.Count -eq 0) {
throw 'The schema has no fields.'
}
$duplicateKeys = @($fields | Group-Object -Property Key | Where-Object Count -gt 1)
if ($duplicateKeys.Count -gt 0) {
throw 'The schema contains duplicate field keys: {0}' -f (($duplicateKeys.Name) -join ', ')
}
Write-Host 'Enter optional filter values. Press Enter to omit a field.'
$filters = [ordered]@{}
foreach ($field in $fields) {
$filterValue = Read-Host (' {0} - {1}' -f $field.Key, $field.Title)
if (-not [string]::IsNullOrEmpty($filterValue)) {
$filters[$field.Key] = $filterValue
}
}
$search = Get-ElixFormsSearchResult -Client $client -BaseUri $BaseUri -Cookie $cookie -SchemaId $schemaId -Filters $filters -PageSize $PageSize
if ($search.Rows.Count -ne $search.TotalRecords) {
throw 'Search reported {0} records but {1} rows were fetched; refusing a partial bulk update.' -f $search.TotalRecords, $search.Rows.Count
}
Show-SearchResults -Columns $search.Columns -Rows $search.Rows
if ($search.Rows.Count -eq 0) {
Write-Host 'Nothing to update.'
return
}
if (-not (Read-YesNo -Prompt 'Proceed with exactly these rows?')) {
Write-Host 'Cancelled after preview.'
return
}
$field = Select-UpdateField -Fields $fields
if ($null -eq $field) {
Write-Host 'Cancelled before choosing an update.'
return
}
$newValue = Read-Host ('New value for {0} - {1} (Enter means an empty string)' -f $field.Key, $field.Title)
Write-Host ''
Write-Warning ('This will irreversibly replace {0} [{1}] in {2} row(s).' -f $field.Title, $field.Key, $search.Rows.Count)
Write-Host ('New value preview: "{0}"' -f (ConvertTo-PreviewText -Value $newValue -MaximumLength 120))
$confirmationPhrase = 'UPDATE {0}' -f $search.Rows.Count
$finalAnswer = Read-Host ('Type "{0}" to continue' -f $confirmationPhrase)
if ($finalAnswer -cne $confirmationPhrase) {
Write-Host 'Final confirmation did not match. No data was changed.'
return
}
$objectIdIndex = Get-ColumnIndex -Columns $search.Columns -Key 'ID_OBJECT'
$dataIdColumnKey = 'S_{0}_ID_GENERICSCHEMA' -f $schemaId
$dataIdIndex = Get-ColumnIndex -Columns $search.Columns -Key $dataIdColumnKey
if ($objectIdIndex -lt 0 -or $dataIdIndex -lt 0) {
throw 'Required search columns ID_OBJECT and/or {0} are missing.' -f $dataIdColumnKey
}
$backupPath = Save-SearchBackup -BackupDirectory $BackupDirectory -BaseUri $BaseUri -SchemaId $schemaId -Filters $filters -PageSize $PageSize -Pages $search.Pages
Write-Host ('Backup written before the first save: {0}' -f $backupPath)
$completed = [Collections.Generic.List[object]]::new()
$failed = [Collections.Generic.List[object]]::new()
for ($rowIndex = 0; $rowIndex -lt $search.Rows.Count; $rowIndex++) {
$row = $search.Rows[$rowIndex]
$objectId = [string] $row[$objectIdIndex]
$dataId = [string] $row[$dataIdIndex]
if ($dataId -notmatch '^\d+$') {
throw 'Row {0} has an invalid generic-schema/data ID: {1}' -f ($rowIndex + 1), $dataId
}
$partName = 'S_{0}_{1}_{2}' -f $schemaId, $dataId, $field.Key
$multipart = New-MultipartFieldBody -Name $partName -Value $newValue
$saveUri = [Uri] ($BaseUri.TrimEnd('/') + '/save')
Write-Host ('[{0}/{1}] Updating object {2} (data ID {3})...' -f ($rowIndex + 1), $search.Rows.Count, $objectId, $dataId)
try {
$saveResult = Invoke-ElixFormsSaveRequest -Uri $saveUri -Cookie $cookie -ContentType $multipart.ContentType -Body $multipart.Body
$errors = @($saveResult.Json.errors.objectList)
$savedObjects = @($saveResult.Json.result.objectList)
if ($errors.Count -gt 0 -or $savedObjects.Count -eq 0) {
throw 'Save response contained {0} error(s) and {1} result object(s).' -f $errors.Count, $savedObjects.Count
}
$savedFields = @($savedObjects[0].sections | ForEach-Object { $_.fields })
$savedField = @($savedFields | Where-Object { $_.key -ceq $field.Key }) | Select-Object -First 1
$returnedValue = if ($null -ne $savedField -and @($savedField.valueList).Count -gt 0) {
[string] $savedField.valueList[0].value
}
else {
$null
}
$completed.Add([pscustomobject]@{
ObjectId = $objectId
DataId = $dataId
ReturnedValue = $returnedValue
})
Write-Host (' Saved. Returned value: "{0}"' -f (ConvertTo-PreviewText -Value $returnedValue -MaximumLength 120))
}
catch {
$failed.Add([pscustomobject]@{
ObjectId = $objectId
DataId = $dataId
Error = $_.Exception.Message
})
Write-Error ('Save failed for object {0} (data ID {1}). Further saves stopped. {2}' -f $objectId, $dataId, $_.Exception.Message) -ErrorAction Continue
break
}
}
Write-Host ''
Write-Host ('Update summary: {0} succeeded, {1} failed, {2} not attempted.' -f $completed.Count, $failed.Count, ($search.Rows.Count - $completed.Count - $failed.Count))
if ($failed.Count -gt 0) {
$failed | Format-Table -AutoSize | Out-Host
Write-Warning ('A partial update may have occurred. Use the backup at {0} to review original values.' -f $backupPath)
return
}
if (Read-YesNo -Prompt 'Re-run the same search to verify the current results?') {
$verification = Get-ElixFormsSearchResult -Client $client -BaseUri $BaseUri -Cookie $cookie -SchemaId $schemaId -Filters $filters -PageSize $PageSize
Show-SearchResults -Columns $verification.Columns -Rows $verification.Rows
if ($filters.Contains($field.Key)) {
Write-Host 'Note: the updated field was a search filter, so changed rows may no longer match.'
}
}
}
finally {
$cookie = $null
if ($null -ne $client) {
$client.Dispose()
}
}
}
if ($MyInvocation.InvocationName -ne '.' -or $RunWhenDotSourced) {
Invoke-ElixFormsMetadataBulkUpdate -BaseUri $BaseUri -BackupDirectory $BackupDirectory -PageSize $PageSize
}