initial commit
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: elixforms-schema-bulk-updater
|
||||
description: Safely inspect an ElixForms schema, search its records, and replace one field across the confirmed result set through the schema HTTP API. Use for guarded bulk schema updates driven by an authenticated browser cookie; do not use for unrelated ElixForms APIs or unattended production writes.
|
||||
---
|
||||
|
||||
# ElixForms Schema Metadata Bulk Update
|
||||
|
||||
Use `scripts/Invoke-ElixFormsSchemaBulkUpdate.ps1` for the operation. It implements schema discovery, ordered field/filter prompts, paginated search, result preview, a timestamped pre-write backup, explicit destructive confirmation, one-field multipart saves, result reporting, and optional verification.
|
||||
|
||||
Before changing the helper or constructing requests another way, read [references/protocol.md](references/protocol.md). In particular, use the search result's generic-schema/data ID in save field names; the supplied HAR proves that this differs from `ID_OBJECT`.
|
||||
|
||||
Run interactively with PowerShell 7 or Windows PowerShell 5.1:
|
||||
|
||||
```powershell
|
||||
.\Invoke-ElixFormsSchemaBulkUpdate.ps1
|
||||
```
|
||||
|
||||
The operator must authenticate in ElixForms first and paste the complete `Cookie` header value into the hidden prompt. Never put the cookie in arguments, source files, logs, backups, or user-visible output.
|
||||
|
||||
Treat the preview and final confirmation as authorization only for the displayed rows, chosen field, and entered value. Do not remove either confirmation or broaden the result set after confirmation. If a save fails, stop further saves and report the partial outcome.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "ElixForms Schema Metadata Bulk Update"
|
||||
short_description: "Safely update one schema field in bulk"
|
||||
default_prompt: "Use $elixforms-schema-bulk-updater to prepare or run a guarded ElixForms schema metadata bulk update."
|
||||
@@ -0,0 +1,37 @@
|
||||
# ElixForms metadata protocol
|
||||
|
||||
## Endpoints
|
||||
|
||||
The default base URI is `https://console-unipr.elixforms.it/AJSRV/metadata`.
|
||||
|
||||
- Schema: `GET list?AJL=it&ID={schemaId}&ACL=true`
|
||||
- Search: `GET search?SEARCH_IN[]={schemaId}&S_{schemaId}_NEW_{fieldKey}={value}&PAGE={page}&AJL=it&PAGESIZE=80&SECURE=true`
|
||||
- Save: `POST save`
|
||||
|
||||
Send the manually obtained cookie and `X-Requested-With: XMLHttpRequest` on every request. For saves, send one UTF-8 `multipart/form-data` part and an explicit byte-based content length.
|
||||
|
||||
## Ordering and identifiers
|
||||
|
||||
Display schema sections by `orderKey`, then their fields by `orderKey`. Search result ordering comes only from the response's `columns` array; each row's values have matching indexes.
|
||||
|
||||
Two leading search columns are special:
|
||||
|
||||
- `ID_OBJECT` identifies the displayed metadata object.
|
||||
- `S_{schemaId}_ID_GENERICSCHEMA` contains the generic-schema/data ID used by the save form.
|
||||
|
||||
Despite the wording in step 14 of the source workflow, do not use `ID_OBJECT` in a save field name. The supplied save HAR contains `S_616_81825_COL0014`, while its response identifies `dataId: 81825` and `objectId: 19519`. Therefore the save part name is:
|
||||
|
||||
```text
|
||||
S_{schemaId}_{genericSchemaDataId}_{fieldKey}
|
||||
```
|
||||
|
||||
The part value is the replacement text itself, including an intentionally empty string. Only the chosen field is sent, minimizing the chance of overwriting concurrent changes to other fields.
|
||||
|
||||
## Safety invariants
|
||||
|
||||
- Fetch and preview all pages, not only page 1.
|
||||
- Keep the full unmodified search page objects in memory.
|
||||
- After final confirmation but before the first save, write all page objects, filters, and timestamp to a UTF-8 JSON backup. Never include the cookie.
|
||||
- Do not write if there are no results, the schema or field cannot be resolved, or confirmation is withheld.
|
||||
- Stop after the first failed save because the operation is not transactional; report completed and failed rows so the operator knows whether a partial update occurred.
|
||||
- A post-update search is verification only. If the updated field was also a filter, changed rows may correctly disappear from the refreshed results.
|
||||
+686
@@ -0,0 +1,686 @@
|
||||
<#
|
||||
.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.
|
||||
#>
|
||||
[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
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
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,
|
||||
|
||||
[Parameter()]
|
||||
[byte[]] $Body,
|
||||
|
||||
[Parameter()]
|
||||
[string] $ContentType
|
||||
)
|
||||
|
||||
$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/')
|
||||
|
||||
if ($PSBoundParameters.ContainsKey('Body')) {
|
||||
if ([string]::IsNullOrWhiteSpace($ContentType)) {
|
||||
throw 'ContentType is required when Body is supplied.'
|
||||
}
|
||||
|
||||
$request.Content = [Net.Http.ByteArrayContent]::new($Body)
|
||||
$null = $request.Content.Headers.TryAddWithoutValidation('Content-Type', $ContentType)
|
||||
$request.Content.Headers.ContentLength = $Body.Length
|
||||
}
|
||||
|
||||
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 = '----ElixFormsBoundary' + [Guid]::NewGuid().ToString('N')
|
||||
$newLine = "`r`n"
|
||||
$bodyText = '--{0}{1}Content-Disposition: form-data; name="{2}"{1}{1}{3}{1}--{0}--{1}' -f $boundary, $newLine, $Name, $Value
|
||||
$encoding = [Text.UTF8Encoding]::new($false)
|
||||
$bytes = $encoding.GetBytes($bodyText)
|
||||
|
||||
return [pscustomobject]@{
|
||||
Boundary = $boundary
|
||||
ContentType = 'multipart/form-data; boundary={0}' -f $boundary
|
||||
Bytes = $bytes
|
||||
}
|
||||
}
|
||||
|
||||
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-ElixFormsRequest -Client $client -Method POST -Uri $saveUri -Cookie $cookie -Body $multipart.Bytes -ContentType $multipart.ContentType
|
||||
$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 '.') {
|
||||
Invoke-ElixFormsMetadataBulkUpdate -BaseUri $BaseUri -BackupDirectory $BackupDirectory -PageSize $PageSize
|
||||
}
|
||||
Reference in New Issue
Block a user