$RequiredPSversion = [version]'7.5.1'
$currentPSVersion = $PSVersionTable.PSVersion
if ($currentPSVersion -lt $RequiredPSversion) { throw "Need PowerShell $RequiredPSversion+, you have $currentPSVersion" } else {write-host "PowerShell version $currentPSVersion is compatible." -ForegroundColor Green}
Write-Host "Required PowerShell version: $RequiredPSversion" -ForegroundColor Blue
if ($currentPSVersion -lt $RequiredPSversion) {
Write-Host "PowerShell $RequiredPSversion or higher is required. You have $currentPSVersion." -ForegroundColor Red
exit 1
} else {
Write-Host "PowerShell version $currentPSVersion is compatible." -ForegroundColor Green
}
function Get-CastIfNumeric {
param([Parameter(Mandatory)][object]$Value)
if ($Value -is [string]) {
$Value = $Value.Trim()
}
if ($Value -match '^[+-]?\d+(\.\d+)?$') {
try {
return [int][double]$Value
} catch {
return $null
}
}
return $null
}
function Limit-FilenameLength {
param (
[string]$FullFilename,
[int]$MaxLength = 100,
[switch]$PreserveExtension
)
if ($PreserveExtension) {
$extension = [IO.Path]::GetExtension($FullFilename)
$basename = [IO.Path]::GetFileNameWithoutExtension($FullFilename)
$maxBaseLength = $MaxLength - $extension.Length
if ($basename.Length -gt $maxBaseLength) {
$basename = $basename.Substring(0, $maxBaseLength)
}
return "$basename$extension"
} else {
# Trim the entire string to max length regardless of extension
return if ($FullFilename.Length -gt $MaxLength) {
$FullFilename.Substring(0, $MaxLength)
} else {
$FullFilename
}
}
}
function Get-FieldTypeByLabel {
param(
[Parameter(Mandatory)][object[]]$LayoutFields
)
$typeByLabel = @{}
foreach ($lf in ($LayoutFields ?? @())) {
if (-not $lf.label) { continue }
$typeByLabel[$lf.label] = ($lf.field_type ?? $lf.type ?? 'Text')
}
return $typeByLabel
}
function FieldsToLabelValueMap {
param([object[]]$Fields)
$map = @{}
foreach ($f in ($Fields ?? @())) {
if (-not $f) { continue }
$label = $f.label
if ([string]::IsNullOrWhiteSpace($label)) { continue }
$map[$label] = $f.value
}
return $map
}
function LabelValueMapToFields {
param(
[Parameter(Mandatory)][hashtable]$Map,
[object[]]$LayoutFields = $null
)
$out = @()
# Preserve layout ordering if given
if ($LayoutFields) {
$layoutLabels = @($LayoutFields | ForEach-Object { $_.label } | Where-Object { $_ })
foreach ($lab in $layoutLabels) {
if ($Map.ContainsKey($lab)) {
$out += @{ $lab = $Map[$lab] }
}
}
# Any extras not in layout
foreach ($k in $Map.Keys | Where-Object { $_ -notin $layoutLabels }) {
$out += @{ $k = $Map[$k] }
}
} else {
foreach ($k in $Map.Keys) { $out += @{ $k = $Map[$k] } }
}
return $out
}
function Is-BlankValue {
param([object]$Value)
if ($null -eq $Value) { return $true }
# AddressData / objects should count as blank only if no meaningful fields
if ($Value -is [hashtable] -or $Value -is [System.Collections.IDictionary] -or $Value -is [pscustomobject]) {
try {
$pairs = $Value.PSObject.Properties | ForEach-Object { $_.Value }
return -not ($pairs | Where-Object { -not (Is-BlankValue $_) } | Select-Object -First 1)
} catch {
return $false
}
}
return [string]::IsNullOrWhiteSpace([string]$Value)
}
function Test-Equiv {
param([string]$A, [string]$B)
$a = Normalize-Text $A; $b = Normalize-Text $B
if (-not $a -or -not $b) { return $false }
if ($a -eq $b) { return $true }
$reA = "(^| )$([regex]::Escape($a))( |$)"
$reB = "(^| )$([regex]::Escape($b))( |$)"
if ($b -match $reA -or $a -match $reB) { return $true }
if ($a.Replace(' ', '') -eq $b.Replace(' ', '')) { return $true }
return $false
}
function remove-hudupasswordfromfolder {
Param (
[Parameter(Mandatory = $true)]
[Int]$Id
)
$AssetPassword = [ordered]@{asset_password = $(Get-HuduPasswords -Id $Id) }
$AssetPassword.asset_password | Add-Member -MemberType NoteProperty -Name password_folder_id -Force -Value $null
Invoke-HuduRequest -Method put -Resource "/api/v1/asset_passwords/$Id" -Body $($AssetPassword | ConvertTo-Json -Depth 10)
}
function New-HuduGlobalPasswordFolder {
param ([Parameter(Mandatory)] [string]$Name)
try {
$res = Invoke-HuduRequest -Method POST -Resource "/api/v1/password_folders" -Body $(@{password_folder = @{name = $Name; security = "all_users"; allowed_groups = @()}} | ConvertTo-Json -Depth 10)
return $res
} catch {
Write-Warning "Failed to create new password folder '$Name'- $_"; return $null;
}
}
function Normalize-Text {
param([string]$s)
if ([string]::IsNullOrWhiteSpace($s)) { return $null }
$s = $s.Trim().ToLowerInvariant()
$s = [regex]::Replace($s, '[\s_-]+', ' ') # "primary_email" -> "primary email"
# strip diacritics (prénom -> prenom)
$formD = $s.Normalize([System.Text.NormalizationForm]::FormD)
$sb = New-Object System.Text.StringBuilder
foreach ($ch in $formD.ToCharArray()){
if ([System.Globalization.CharUnicodeInfo]::GetUnicodeCategory($ch) -ne
[System.Globalization.UnicodeCategory]::NonSpacingMark) { [void]$sb.Append($ch) }
}
($sb.ToString()).Normalize([System.Text.NormalizationForm]::FormC)
}
function Get-Similarity {
param([string]$A, [string]$B)
$a = [string](Normalize-Text $A)
$b = [string](Normalize-Text $B)
if ([string]::IsNullOrEmpty($a) -and [string]::IsNullOrEmpty($b)) { return 1.0 }
if ([string]::IsNullOrEmpty($a) -or [string]::IsNullOrEmpty($b)) { return 0.0 }
$n = [int]$a.Length
$m = [int]$b.Length
if ($n -eq 0) { return [double]($m -eq 0) }
if ($m -eq 0) { return 0.0 }
$d = New-Object 'int[,]' ($n+1), ($m+1)
for ($i = 0; $i -le $n; $i++) { $d[$i,0] = $i }
for ($j = 0; $j -le $m; $j++) { $d[0,$j] = $j }
for ($i = 1; $i -le $n; $i++) {
$im1 = ([int]$i) - 1
$ai = $a[$im1]
for ($j = 1; $j -le $m; $j++) {
$jm1 = ([int]$j) - 1
$cost = if ($ai -eq $b[$jm1]) { 0 } else { 1 }
$del = [int]$d[$i, $j] + 1
$ins = [int]$d[$i, $jm1] + 1
$sub = [int]$d[$im1,$jm1] + $cost
$d[$i,$j] = [Math]::Min($del, [Math]::Min($ins, $sub))
}
}
$dist = [double]$d[$n,$m]
$maxLen = [double][Math]::Max($n,$m)
return 1.0 - ($dist / $maxLen)
}
function Get-SimilaritySafe { param([string]$A,[string]$B)
if ([string]::IsNullOrWhiteSpace($A) -or [string]::IsNullOrWhiteSpace($B)) { return 0.0 }
$score = Get-Similarity $A $B
write-host "$A ... $B SCORED $score"
return $score
}
function Get-CastIfBoolean {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object]$Value,
[array]$trueVals = @("true","t","yes","y","1","on"),# Accepted truthy keyword mappings
[array]$falseVals = @("false","f","no","n","0","off"), # Accepted falsey keyword mappings
[bool]$allowFuzzy=$true
)
if ($Value -is [string]) {
$Value = $Value.Trim()
if ([string]::IsNullOrWhiteSpace($Value)) { return $null }
}
# Already a real boolean? return it
if ($Value -is [bool]) {
return $Value
}
if ($Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal]) {
if ([int]$Value -eq 1) { return $true }
if ([int]$Value -eq 0) { return $false }
return $null
}
if ($Value -is [string]) {
$lower = $Value.ToLowerInvariant()
if ($trueVals -contains $lower) { return $true }
if ($falseVals -contains $lower) { return $false }
if ($true -eq $allowFuzzy){
foreach ($t in $truevals){
if ($value -ilike "*$t" -or $value -ilike "$t*") {return $true}
}
foreach ($f in $falseVals){
if ($value -ilike "*$f" -or $value -ilike "$f*") {return $false}
}
foreach ($t in $truevals){
if ($value -ilike "*$t*") {return $true}
}
foreach ($f in $falseVals){
if ($value -ilike "*$f*") {return $false}
}
}
return $null
}
return $null
}
function Set-SmooshAssetFieldsToField {
param (
[PSCustomObject]$sourceAsset,
[array]$smooshsource,
[bool]$includeBlanks=$false
)
if ($excludeHTMLinSMOOSH -and $true -eq $excludeHTMLinSMOOSH) {
$lineDelmit = " "
} else {
$lineDelmit = "
"
}
foreach ($sourcefieldsmoosh in $smooshsource) {
if ($null -eq $($($sourceasset.fields | where-object {$_.label -eq $sourcefieldsmoosh}).value)){
if ($false -eq $includeBlanks) {continue}
}
if ($includeLabelInSmooshedValues){
$header = "$sourcefieldsmoosh -"
} else {$header = ""}
$textToUse = ""
if ("$($($sourceasset.fields | where-object {$_.label -eq $sourcefieldsmoosh}).value)" -ilike '*list_id*'){
$precastValue="$($($sourceasset.fields | where-object {$_.label -eq $sourcefieldsmoosh}).value)"
$listItemId = $null;
$listItemId = $(SafeDecode "$($($sourceasset.fields | where-object {$_.label -eq $sourcefieldsmoosh}).value)").list_ids[0]
$textToUse = $($(get-hudulists).list_items | where-object {$_.id -eq $listItemId} | select-object -first 1).name
Write-Host "non-empty source val [for smoosh] appears to contain listIDs; Raw val '$($precastValue)'... $($textToUse)" -foregroundColor DarkCyan
} else {
$textToUse = "$($($sourceasset.fields | where-object {$_.label -ieq $sourcefieldsmoosh}).value)"
}
# generate single entry
$smooshin=@"
$header
$textToUse
"@
# append to smoosh
$smoosh=@"
$smoosh
$lineDelmit
$smooshin
"@
}
if ($excludeHTMLinSMOOSH -and $true -eq $excludeHTMLinSMOOSH) {
Write-Host "Not using HTML for smoosh; Cleaning values to text-friendly single-line."
$smoosh = $smoosh -replace "`r?`n", ' '
$smoosh = $smoosh -replace '\s{2,}', ' '
$smoosh = Remove-HtmlTags -InputString $smoosh
$smoosh = $smoosh.Trim()
}
write-host "Smooshed: $smoosh"
write-host "$($($smoosh | ConvertTo-Json -depth 66).ToString())"
return $smoosh
}
function Get-RelinkableAssetTagLayoutFields {
param (
[int]$fromLayoutId
)
$linkableLayouts = @()
$labelLinkMap = @{}
$relinkables=$($(Get-HuduAssetLayouts -id $fromLayoutId).fields | where-object {$_.field_type -eq "AssetTag" -and $null -ne $_.linkable_id})
write-host "$($relinkables.count) are likely relinkable."
$linkableIDX=0
foreach ($relinkable in $relinkables){
$linkableIDX=$linkableIDX+1
$linkablelayout = Get-HuduAssetLayouts -id $relinkable.linkable_id
if (-not $linkablelayout -or $null -eq $linkablelayout) {continue}
$labelLinkMap[$relinkable.label]=$linkablelayout
write-host "linkable $linkableIDX of $($relinkables.count): label $($relinkable.label) is linkable to $($linkablelayout.name)"
$linkableLayouts+=$linkablelayout
}
return $labelLinkMap
}
function Get-CleansedEmailAddresses {
<#
returns a semicolon-delimited series of email addresses (if going to Text field, it's good to do this after stripping HTML, as to remove table row / column names)
#>
param (
[string]$InputString,
[string]$pattern = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'
)
$cleansed = ( $inputString | Select-String -AllMatches -Pattern $pattern ).Matches.Value -join '; '
return "$cleansed".Trim()
}
function Get-SmooshedLinkableDescription {
param (
[array]$linkableObjects
)
$description=""
if (-not $linkableObjects -or $linkableObjects.count -lt 1) {
return ""
}
foreach ($linkable in $linkableObjects) {
if ($linkable.linkedasset.url){
$descriptor=@"
Related $($linkable.LinkedLayout.name) - $($linkable.LinkedAsset.name)
"@
} else {
$descriptor=@"
Related $($linkable.LinkedLayout.name) - $($linkable.LinkedAsset.name)
"@
}
$description = "$description
$descriptor"
}
return $description
}
function Get-HuduModule {
param (
[string]$HAPImodulePath = "C:\Users\$env:USERNAME\Documents\GitHub\HuduAPI\HuduAPI\HuduAPI.psm1",
[bool]$use_hudu_fork = $true
)
if ($true -eq $use_hudu_fork) {
if (-not $(Test-Path $HAPImodulePath)) {
$dst = Split-Path -Path (Split-Path -Path $HAPImodulePath -Parent) -Parent
Write-Host "Using Lastest Master Branch of Hudu Fork for HuduAPI"
$zip = "$env:TEMP\huduapi.zip"
Invoke-WebRequest -Uri "https://github.com/Hudu-Technologies-Inc/HuduAPI/archive/refs/heads/master.zip" -OutFile $zip
Expand-Archive -Path $zip -DestinationPath $env:TEMP -Force
$extracted = Join-Path $env:TEMP "HuduAPI-master"
if (Test-Path $dst) { Remove-Item $dst -Recurse -Force }
Move-Item -Path $extracted -Destination $dst
Remove-Item $zip -Force
}
} else {
Write-Host "Assuming PSGallery Module if not already locally cloned at $HAPImodulePath"
}
if (Test-Path $HAPImodulePath) {
Import-Module $HAPImodulePath -Force
Write-Host "Module imported from $HAPImodulePath"
} elseif ((Get-Module -ListAvailable -Name HuduAPI).Version -ge [version]'2.4.4') {
Import-Module HuduAPI
Write-Host "Module 'HuduAPI' imported from global/module path"
} else {
Install-Module HuduAPI -MinimumVersion 2.4.5 -Scope CurrentUser -Force
Import-Module HuduAPI
Write-Host "Installed and imported HuduAPI from PSGallery"
}
}
function Set-HuduInstance {
$HuduBaseURL = $HuduBaseURL ?? $((Read-Host -Prompt 'Set the base domain of your Hudu instance (e.g https://myinstance.huducloud.com)') -replace '[\\/]+$', '') -replace '^(?!https://)', 'https://'
$HuduAPIKey = $HuduAPIKey ?? "$(read-host "Please Enter Hudu API Key")"
while ($HuduAPIKey.Length -ne 24) {
$HuduAPIKey = (Read-Host -Prompt "Get a Hudu API Key from $($settings.HuduBaseDomain)/admin/api_keys").Trim()
if ($HuduAPIKey.Length -ne 24) {
Write-Host "This doesn't seem to be a valid Hudu API key. It is $($HuduAPIKey.Length) characters long, but should be 24." -ForegroundColor Red
}
}
New-HuduAPIKey $HuduAPIKey
Clear-Host
New-HuduBaseURL $HuduBaseURL
}
function Get-RelinkableRelationsForAsset {
param (
[PSCustomObject]$sourceAsset,
[hashtable]$labelLinkMap
)
$linkableObjects = @()
foreach ($linkableField in $sourceAsset.fields | Where-Object {
$_.label -and $_.label -in $labelLinkMap.Keys
}) {
$layoutForLinking = $labelLinkMap[$linkableField.label]
try {
$linkedItems = $null
if ($linkableField.value -is [string] -and $linkableField.value.Trim().StartsWith("[")) {
$linkedItems = $linkableField.value | ConvertFrom-Json
}
foreach ($linkedItem in $linkedItems) {
$linkedAsset = Get-HuduAssets -Id $linkedItem.id
if ($false -eq $includeRelationsForArchived -and $true -eq $linkedAsset.archived){
write-host "archived link, continuing"
continue
}
$linkableObjects+=[PSCustomObject]@{
SourceAssetId = $sourceAsset.id
SourceField = $linkableField.label
LinkedAsset = $linkedAsset
LinkedLayout = $layoutForLinking
}
}
}
catch {
Write-Warning "Could not parse linked values for field [$($linkableField.label)] in asset [$($sourceAsset.id)]"
}
}
return $linkableObjects
}
$PerJobSettings = @'
# if fields are blank, exclude during smoosh procress?
$includeblanksduringsmoosh = $false
# relate archived objects to new asset / object
$includeRelationsForArchived = $true
# set below to true if smooshing to plaintext field, otherwise leave for richtext field
# (strip html when going to text field)
$excludeHTMLinSMOOSH = $false
# include description of related objects in smoosh
# related objects will have a 1-line description based on related object type and name
$describeRelatedInSmoosh = $false
# include label - above value in smooshed? IE -
# label -
# value
$includeLabelInSmooshedValues = $true
'@
function Remove-HtmlTags {
param (
[string]$InputString
)
$tags = @(
'hr','br', 'tr', 'td', 'th', 'table', 'div', 'span',
'p', 'ul', 'ol', 'li', 'h[1-6]', 'strong', 'em', 'b', 'i',
'colgroup', 'col', 'input', 'column', 'section', 'article',
'header', 'footer', 'aside', 'nav', 'main', 'figure', 'figcaption',
'blockquote', 'pre', 'address', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'thead', 'tbody', 'tfoot','script','noscript','style','template','head','svg','math'
)
$cleaned = $InputString
foreach ($tag in $tags) {
# Regex matches both opening and closing
$pattern = "<\/?$tag\b[^>]*>"
$cleaned = [regex]::Replace($cleaned, $pattern, " ", "IgnoreCase")
}
return $cleaned.Trim()
}
function build-templatemap {
param ([array]$destfields,[string]$mapfile)
# Build entries like: @{from='';to='Some Label'}
$mapEntries = foreach ($f in $destfields) {
if ($f.field_type -eq "AssetTag") {write-host "Skipping asset tag for $($f.label), those will be relinked as relations"; continue}
$toEsc = ([string]$f.label) -replace "'", "''" # double single-quotes inside single-quoted PS strings
$desttype = ([string]$($f.field_type ?? $f.type)) -replace "'", "''" # double single-quotes inside single-quoted PS strings
$req = ([string]$($f.required ?? $false)) -replace "'", "''" # double single-quotes inside single-quoted PS strings
if ($desttype -eq "ListSelect") {
$ListItems = $(Get-HuduLists -id $f.list_id).list_items.name | Foreach-Object {"'$_'=@{whenvalues=@()}"}
"@{to='$toEsc'; from=''; add_listitems='false'; list_id=$($f.list_id); dest_type='ListSelect'; required='$req'; Mapping=@{
$($listitems -join "`n")
}}"
} elseif ($desttype -eq "AddressData") {
"@{to='$toEsc'; from='Meta'; dest_type='AddressData'; required='$req'; address=@{
address_line_1=@{from=''}
address_line_2=@{from=''}
city=@{from=''}
state=@{from=''}
zip=@{from=''}
country_name=@{from=''}
}}"
} else {
"@{from='';to='$toEsc'; dest_type='$desttype'; required='$req'; striphtml='False'}"
}
}
# Wrap and write
$mappingText = @'
# source
$CONSTANTS=@(
## @{literal="constval";to_label="constfield"}
)
$SMOOSHLABELS=@()
$mapping=@(
'@ + ($mapEntries -join ",`n") + @'
)
'@ + @"
$PerJobSettings
"@
Set-Content -Path $mapfile -Value $mappingText -Encoding UTF8
}
function Select-ObjectFromList($objects, $message, $inspectObjects = $false, $allowNull = $false) {
$validated = $false
while (-not $validated) {
if ($allowNull) {
Write-Host "0: None/Custom"
}
for ($i = 0; $i -lt $objects.Count; $i++) {
$object = $objects[$i]
$displayLine = if ($inspectObjects) {
"$($i+1): $(Write-InspectObject -object $object)"
} elseif ($null -ne $object.OptionMessage) {
"$($i+1): $($object.OptionMessage)"
} elseif ($null -ne $object.name) {
"$($i+1): $($object.name)"
} else {
"$($i+1): $($object)"
}
Write-Host $displayLine -ForegroundColor $(if ($i % 2 -eq 0) { 'Cyan' } else { 'Yellow' })
}
$choice = Read-Host $message
if (-not ($choice -as [int])) {
Write-Host "Invalid input. Please enter a number." -ForegroundColor Red
continue
}
$choice = [int]$choice
if ($choice -eq 0 -and $allowNull) {
return $null
}
if ($choice -ge 1 -and $choice -le $objects.Count) {
return $objects[$choice - 1]
} else {
Write-Host "Invalid selection. Please enter a number from the list." -ForegroundColor Red
}
}
}
function Get-UniqueListName {
param([Parameter(Mandatory)][string]$BaseName,[bool]$allowReuse=$false)
$name = $BaseName.Trim()
$i = 0
while ($true) {
$existing = Get-HuduLists -name $name
if (-not $existing) { return $name }
if ($existing -and $true -eq $allowReuse) {return $existing}
$i++
$name = "{0}-{1}" -f $BaseName.Trim(), $i
}
}
function Get-NormalizedDropdownOptions {
param([Parameter(Mandatory)]$OptionsRaw)
$lines =
if ($null -eq $OptionsRaw) { @() }
elseif ($OptionsRaw -is [string]) { $OptionsRaw -split "`r?`n" }
elseif ($OptionsRaw -is [System.Collections.IEnumerable]) { @($OptionsRaw) }
else { @("$OptionsRaw") }
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$out = New-Object System.Collections.Generic.List[string]
foreach ($l in $lines) {
$x = "$l".Trim()
if ($x -ne "" -and $seen.Add($x)) { $out.Add($x) }
}
if ($out.Count -eq 0) { @('None','N/A') } elseif ($out.Count -eq 1) { @('None',$out[0] ?? "N/A") } else { $out.ToArray() }
}
function Get-FieldValueByLabel {
param([array]$Fields, [string]$Label)
if (-not $Label) { return $null }
($Fields | Where-Object { $_.label -eq $Label } | Select-Object -First 1).value
}
function Normalize-Region {
param([string]$State)
if (-not $State) { return $null }
$s = $State.Trim()
# Already 2 letters?
if ($s -match '^[A-Za-z]{2}$') { return $s.ToUpper() }
$us = @{
'alabama'='AL'; 'alaska'='AK'; 'arizona'='AZ'; 'arkansas'='AR'; 'california'='CA'
'colorado'='CO'; 'connecticut'='CT'; 'delaware'='DE'; 'florida'='FL'; 'georgia'='GA'
'hawaii'='HI'; 'idaho'='ID'; 'illinois'='IL'; 'indiana'='IN'; 'iowa'='IA'
'kansas'='KS'; 'kentucky'='KY'; 'louisiana'='LA'; 'maine'='ME'; 'maryland'='MD'
'massachusetts'='MA'; 'michigan'='MI'; 'minnesota'='MN'; 'mississippi'='MS'; 'missouri'='MO'
'montana'='MT'; 'nebraska'='NE'; 'nevada'='NV'; 'new hampshire'='NH'; 'new jersey'='NJ'
'new mexico'='NM'; 'new york'='NY'; 'north carolina'='NC'; 'north dakota'='ND'
'ohio'='OH'; 'oklahoma'='OK'; 'oregon'='OR'; 'pennsylvania'='PA'; 'rhode island'='RI'
'south carolina'='SC'; 'south dakota'='SD'; 'tennessee'='TN'; 'texas'='TX'; 'utah'='UT'
'vermont'='VT'; 'virginia'='VA'; 'washington'='WA'; 'west virginia'='WV'; 'wisconsin'='WI'; 'wyoming'='WY'
'district of columbia'='DC'; 'washington dc'='DC'; 'dc'='DC'
}
$key = $s.ToLower()
if ($us.ContainsKey($key)) { return $us[$key] }
return $s # fallback (leave as-is)
}
function Normalize-CountryName {
param([string]$Country)
if (-not $Country) { return $null }
$c = $Country.Trim()
$map = @{
'us'='USA'; 'u.s.'='USA'; 'u.s.a'='USA'; 'usa'='USA'; 'united states'='USA'; 'united states of america'='USA'
'uk'='United Kingdom'; 'u.k.'='United Kingdom'; 'gb'='United Kingdom'; 'gbr'='United Kingdom'
'uae'='United Arab Emirates'
}
$key = $c.ToLower().Replace('.','')
if ($map.ContainsKey($key)) { return $map[$key] }
# Title-case fallback
return -join ($c.ToLower().Split(' ') | ForEach-Object { if ($_){ $_.Substring(0,1).ToUpper()+$_.Substring(1) } })
}
function Normalize-Zip {
param([string]$Zip)
if (-not $Zip) { return $null }
$z = $Zip -replace '\s+', '' # collapse spaces (e.g., “802 02”)
return $z.Trim()
}
function Write-InspectObject {
param (
[object]$object,
[int]$Depth = 32,
[int]$MaxLines = 16
)
$stringifiedObject = $null
if ($null -eq $object) {
return "Unreadable Object (null input)"
}
# Try JSON
$stringifiedObject = try {
$json = $object | ConvertTo-Json -Depth $Depth -ErrorAction Stop
"# Type: $($object.GetType().FullName)`n$json"
} catch { $null }
# Try Format-Table
if (-not $stringifiedObject) {
$stringifiedObject = try {
$object | Format-Table -Force | Out-String
} catch { $null }
}
# Try Format-List
if (-not $stringifiedObject) {
$stringifiedObject = try {
$object | Format-List -Force | Out-String
} catch { $null }
}
# Fallback to manual property dump
if (-not $stringifiedObject) {
$stringifiedObject = try {
$props = $object | Get-Member -MemberType Properties | Select-Object -ExpandProperty Name
$lines = foreach ($p in $props) {
try {
"$p = $($object.$p)"
} catch {
"$p = "
}
}
"# Type: $($object.GetType().FullName)`n" + ($lines -join "`n")
} catch {
"Unreadable Object"
}
}
if (-not $stringifiedObject) {
$stringifiedObject = try {"$($($object).ToString())"} catch {$null}
}
# Truncate to max lines if necessary
$lines = $stringifiedObject -split "`r?`n"
if ($lines.Count -gt $MaxLines) {
$lines = $lines[0..($MaxLines - 1)] + "... (truncated)"
}
return $lines -join "`n"
}
function Test-DateAfter {
param(
[Parameter(Mandatory)][string]$DateString,
[datetime]$Cutoff = [datetime]'1000-01-01'
)
$dt = $null
$ok = [datetime]::TryParseExact(
$DateString,
'yyyy-MM-dd',
[System.Globalization.CultureInfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::AssumeUniversal,
[ref]$dt
)
if (-not $ok) { return $false } # invalid format → fail
return ($dt -ge $Cutoff)
}
function Get-CoercedDate {
param(
[Parameter(Mandatory)]
[object]$InputDate, # allow string or [datetime]
[datetime]$Cutoff = [datetime]'1000-01-01',
[ValidateSet('DD.MM.YYYY','YYYY.MM.DD','MM/DD/YYYY')]
[string]$OutputFormat = 'MM/DD/YYYY'
)
$Inv = [System.Globalization.CultureInfo]::InvariantCulture
if ($InputDate -is [datetime]) {
$dt = [datetime]$InputDate
}
else {
$text = "$InputDate".Trim()
if ([string]::IsNullOrWhiteSpace($text)) { return $null }
# 2) Try strict formats first via ParseExact
$formats = @(
'MM/dd/yyyy HH:mm:ss'
'MM/dd/yyyy hh:mm:ss tt'
'MM/dd/yyyy'
)
$dt = $null
$ok = $false
foreach ($fmt in $formats) {
try {
$dt = [System.DateTime]::ParseExact($text, $fmt, $Inv)
$ok = $true
break
} catch {
# ignore and try next format
}
}
# 3) Fallback: general Parse (handles lots of “normal” date strings)
if (-not $ok) {
try {
$dt = [System.DateTime]::Parse($text, $Inv)
} catch {
return $null
}
}
}
if ($dt -lt $Cutoff) { return $null }
switch ($OutputFormat) {
'DD.MM.YYYY' { $dt.ToString('dd.MM.yyyy', $Inv) }
'YYYY.MM.DD' { $dt.ToString('yyyy.MM.dd', $Inv) }
'MM/DD/YYYY' { $dt.ToString('MM/dd/yyyy', $Inv) }
}
}
function Set-LayoutsForTransfer {
param ($allLayouts)
$layoutMap = @{}
foreach ($layout in $allLayouts) {
$layoutMap[$layout.id] = $layout
}
$layoutSummaries = $allLayouts | ForEach-Object {
[PSCustomObject]@{
ID = $_.id
OptionMessage = "$($_.name): $( ($_.fields).Count ) fields with $($_.assetsInLayoutCount) assets present"
Name = $_.name
}}
write-host "$(if ($layoutSummaries.count -ne $allLayouts.count) {
"$([int]$allLayouts.count - [int]$layoutSummaries.count) layouts were excluded due to not having fields, not having assets, or being otherwise ineligible."
} else {
"created user-friendly summaries for $($layoutSummaries.count) asset layouts"
})" -ForegroundColor darkcyan
$sourceLayout = $null
$destLayout = $null
while ($true) {
$sourceSummary = Select-ObjectFromList -objects $layoutSummaries -message "Which source / origin asset layout?" -allowNull $false -inspectObjects $inspectlayouts
$sourceLayout = $layoutMap[$sourceSummary.ID]
$destSummaries = $layoutSummaries | Where-Object { $_.ID -ne $sourceLayout.id }
$destSummary = Select-ObjectFromList -objects $destSummaries -message "Which dest / target asset layout?" -allowNull $false -inspectObjects $inspectlayouts
$destLayout = $layoutMap[$destSummary.ID]
if ($($null -ne $sourceLayout -and $null -ne $destLayout) -and $(Select-ObjectFromList -objects @("yes","no") -message "You've selected source layout as: $($sourceLayout.name) and dest layout as: $($destLayout.name). Proceed?") -eq "yes") {
return @{
SourceLayout = $sourceLayout
DestLayout = $destLayout
}
} else {
Write-Host "Opting to re-select."
}
}
}
function Get-SourceListItemNameFieldFromID {
param ([string]$RawValue,[string]$FieldLabel)
if ([string]::IsNullOrWhiteSpace($RawValue)){return $null}
$mapped = $null
if ("$RawValue" -ilike '*list_id*') {
try {
$listItemId = ($RawValue | ConvertFrom-Json).list_ids[0]
if ($FieldLabel) {
$mapped = (Get-HuduLists -Name $FieldLabel).list_items |
Where-Object { $_.id -eq $listItemId } |
Select-Object -ExpandProperty name -ErrorAction SilentlyContinue
}
if ($mapped) { return $mapped }
}
catch {
Write-Host "Error transforming list_id source value '$RawValue' — $_"
return $mapped
}
} else {
Write-Host "list item is presumed human-readable"
return $RawValue
}
}
function SafeDecode {
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[AllowNull()]
[object]$InputObject
)
if ($null -eq $InputObject) { return $null }
if ($InputObject -isnot [string]) {
return $InputObject
}
$s = $InputObject.Trim()
if ([string]::IsNullOrWhiteSpace($s)) { return $null }
try {
return $s | ConvertFrom-Json -ErrorAction Stop
} catch {
# Not valid JSON; just return the original string
return $InputObject
}
}
function Set-MappedListSelectItemFromuserMapping {
[OutputType([hashtable])]
[CmdletBinding()]
param(
# Hashtable of key → string[] (whenvalues)
[Parameter(Mandatory)]
[hashtable]$Mapping,
# Raw field value (field.value from the source asset)
[Parameter(Mandatory)]
$RawValue,
# Optional: for list_id → label resolution (if needed later)
[Parameter()]
[hashtable]$SourceListItemMap,
[Parameter()]
[string]$FieldLabel
)
$result = @{
MatchFound = $false
Key = $null # destination list item label, e.g. 'these options'
Normalized = $RawValue # coerced/clean value used for comparison
NeedsNewItem = $true
}
# --- 1. Normalize / coerce list_id JSON if present ---
$listItemValue = $RawValue
if ("$RawValue" -ilike '*list_id*') {
try {
$listItemId = ($RawValue | ConvertFrom-Json).list_ids[0]
$mapped = $null
if ($FieldLabel) {
$mapped = (Get-HuduLists -Name $FieldLabel).list_items |
Where-Object { $_.id -eq $listItemId } |
Select-Object -ExpandProperty name -ErrorAction SilentlyContinue
}
if ($mapped) { $listItemValue = $mapped }
}
catch {
Write-Host "Error transforming list_id source value '$RawValue' — $_"
}
}
$result.Normalized = $listItemValue
$normalizedListItemValue = Remove-HtmlTags -InputString "$listItemValue"
# --- 2. Filter mappings to only non-empty whenvalues arrays ---
$nonEmptyMappings = $Mapping.GetEnumerator() | Where-Object {
$_.Value -and ($_.Value | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
if ($nonEmptyMappings.Count -eq 0) {
return $result
}
# --- 3. Try to match: find key whose whenvalues contains our value ---
foreach ($entry in $nonEmptyMappings) {
$keyName = $entry.Key # e.g. 'these options' / 'milk'
$whenvalues = $entry.Value # string[] like @('cloud','cloud service')
foreach ($potentialMatch in $whenvalues) {
if ($(Test-Equiv -A "$potentialMatch" -B "$listItemValue") -or $(Test-Equiv -A "$potentialMatch" -B "$normalizedListItemValue")) {
$result.MatchFound = $true
$result.Key = $keyName # <- THIS is what Hudu wants
$result.NeedsNewItem = $false
return $result
}
}
}
# No match
return $result
}
function Convert-FieldArrayToMap {
param([Parameter(Mandatory)][object[]]$FieldArray)
$map = @{}
foreach ($ht in $FieldArray) {
if ($ht -isnot [hashtable] -and $ht -isnot [System.Collections.IDictionary]) { continue }
foreach ($k in $ht.Keys) {
$map[$k] = $ht[$k]
}
}
return $map
}
function Merge-HuduFieldMaps {
[CmdletBinding()]
param(
[Parameter(Mandatory)][hashtable]$SourceMap, # transformed/source
[Parameter(Mandatory)][hashtable]$DestMap, # matched/dest existing
[Parameter(Mandatory)][object[]]$LayoutFields, # dest layout fields
[ValidateSet('Merge-FillBlanks','Merge-PreferSource','Merge-Concat')]
[string]$Mode = 'Merge-FillBlanks',
# For Merge-Concat: which field types should be concatenated?
[string[]]$ConcatTypes = @('RichText','Text', "Heading", "ConfidentialText","Password"),
# Separators
[string]$RichTextSeparator = "
",
[string]$TextSeparator = "`n`n---`n`n",
# Provenance stamping
[switch]$StampProvenance,
[string]$SourceStamp = "Imported (source)",
[string]$DestStamp = "Existing (dest)"
)
$typeByLabel = Get-FieldTypeByLabel -LayoutFields $LayoutFields
$out = @{}
$labels = @($SourceMap.Keys + $DestMap.Keys) | Select-Object -Unique
foreach ($label in $labels) {
$listItemId = $null; $humanReadable = $null;
$src = $SourceMap[$label]
$dst = $DestMap[$label]
$srcBlank = Is-BlankValue $src
$dstBlank = Is-BlankValue $dst
# only fetched-dest will be in non-human format when list
if (-not $dstBlank -and $dst -ilike '*list_id*'){
try {
$listItemId = $(SafeDecode $dst).list_ids[0]
$humanReadable = $($(get-hudulists).list_items | where-object {$_.id -eq $listItemId} | select-object -first 1).name
} catch {
Write-Host "Error transforming list_id source value '$dst' — $_"
}
if (-not ([string]::IsNullOrWhiteSpace($humanReadable))) {
$dst = $humanReadable
write-host "existing dest value for '$label' is a list item ID ($listItemId), transformed to human-readable '$dst'"
}
}
$fieldType = $typeByLabel[$label]
if (-not $fieldType) { $fieldType = 'Text' }
switch ($Mode) {
'Merge-FillBlanks' {
# dest wins unless blank
if (-not $dstBlank) {
$out[$label] = $dst
} elseif (-not $srcBlank) {
$out[$label] = $src
}
}
'Merge-PreferSource' {
# source wins unless blank
if (-not $srcBlank) {
$out[$label] = $src
} elseif (-not $dstBlank) {
$out[$label] = $dst
}
}
'Merge-Concat' {
$isConcat = $ConcatTypes -contains $fieldType
if (-not $isConcat) {
# For non-concat field types, default to PreferSource (tweak if you prefer)
if (-not $srcBlank) { $out[$label] = $src }
elseif (-not $dstBlank) { $out[$label] = $dst }
break
}
# Concat path (only when both present)
if (-not $srcBlank -and -not $dstBlank) {
$sep = if ($fieldType -eq 'RichText') { $RichTextSeparator } else { $TextSeparator }
if ($StampProvenance) {
if ($fieldType -eq 'RichText') {
$lhs = "$SourceStamp
$src"
$rhs = "$DestStamp
$dst"
$out[$label] = "$lhs$sep$rhs"
} else {
$lhs = "$SourceStamp`n$src"
$rhs = "$DestStamp`n$dst"
$out[$label] = "$lhs$sep$rhs"
}
} else {
$out[$label] = ([string]$src) + $sep + ([string]$dst)
}
} elseif (-not $srcBlank) {
$out[$label] = $src
} elseif (-not $dstBlank) {
$out[$label] = $dst
}
}
}
}
return $out
}
function FieldListToMap {
param([object[]]$FieldList)
$map = @{}
foreach ($ht in ($FieldList ?? @())) {
if ($ht -isnot [System.Collections.IDictionary]) { continue }
foreach ($k in $ht.Keys) {
$map[$k] = $ht[$k] # last wins
}
}
$map
}
function MapToFieldList {
param(
[hashtable]$Map,
[object[]]$LayoutFields = $null # optional for ordering
)
$out = @()
if ($LayoutFields) {
foreach ($lf in $LayoutFields) {
$label = $lf.label
if ($label -and $Map.ContainsKey($label)) {
$out += @{ $label = $Map[$label] }
}
}
# include any extras not in layout
foreach ($k in $Map.Keys | Where-Object { $_ -notin ($LayoutFields.label) }) {
$out += @{ $k = $Map[$k] }
}
} else {
foreach ($k in $Map.Keys) { $out += @{ $k = $Map[$k] } }
}
$out
}
function Ensure-HuduListItemByName {
param(
[Parameter(Mandatory)][int]$ListId,
[Parameter(Mandatory)][string]$Name,
[hashtable]$listNameExistsByListId
)
$nameTrim = $Name.Trim()
$needle = $nameTrim.ToLowerInvariant()
if (-not $listNameExistsByListId.ContainsKey($ListId)) {
Refresh-ListCache
}
$map = $listNameExistsByListId[$ListId]
if ($map -and $map.ContainsKey($needle)) {
return $map[$needle] # return canonical name as stored
}
# Add item to list
$list = Get-HuduLists -Id $ListId
$listName = $list.name
$items = @()
foreach ($existing in ($list.list_items ?? @())) {
$items += @{ id = [int]$existing.id; name = [string]$existing.name }
}
$items += @{ name = $nameTrim }
$null = Set-HuduList -Id $ListId -Name $listName -ListItems $items
# refresh cache and return
$listNameExistsByListId = Refresh-ListCache
$map = $listNameExistsByListId[$ListId]
if ($map.ContainsKey($needle)) { return $map[$needle] }
throw "Failed to add/list item '$Name' to list $ListId"
}
function Refresh-ListCache {
$listNameExistsByListId = @{}
foreach ($l in Get-HuduLists) {
$lid = [int]$l.id
$map = @{}
foreach ($it in ($l.list_items ?? @())) {
if ($it.name) {
$map[$it.name.ToString().Trim().ToLowerInvariant()] = [string]$it.name
}
}
$listNameExistsByListId[$lid] = $map
}
return $listNameExistsByListId
}
function Write-ErrorObjectsToFile {
param (
[Parameter(Mandatory)]
[object]$ErrorObject,
[Parameter()]
[string]$Name = "unnamed",
[Parameter()]
[ValidateSet("Black","DarkBlue","DarkGreen","DarkCyan","DarkRed","DarkMagenta","DarkYellow","Gray","DarkGray","Blue","Green","Cyan","Red","Magenta","Yellow","White")]
[string]$Color
)
$stringOutput = try {
$ErrorObject | Format-List -Force | Out-String
} catch {
"Failed to stringify object: $_"
}
$propertyDump = try {
$props = $ErrorObject | Get-Member -MemberType Properties | Select-Object -ExpandProperty Name
$lines = foreach ($p in $props) {
try {
"$p = $($ErrorObject.$p)"
} catch {
"$p = "
}
}
$lines -join "`n"
} catch {
"Failed to enumerate properties: $_"
}
$logContent = @"
==== OBJECT STRING ====
$stringOutput
==== PROPERTY DUMP ====
$propertyDump
"@
if ($ErroredItemsFolder -and (Test-Path $ErroredItemsFolder)) {
$SafeName = ($Name -replace '[\\/:*?"<>|]', '_') -replace '\s+', ''
if ($SafeName.Length -gt 60) {
$SafeName = $SafeName.Substring(0, 60)
}
$filename = "${SafeName}_error_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
$fullPath = Join-Path $ErroredItemsFolder $filename
Set-Content -Path $fullPath -Value $logContent -Encoding UTF8
if ($Color) {
Write-Host "Error written to $fullPath" -ForegroundColor $Color
} else {
Write-Host "Error written to $fullPath"
}
}
if ($Color) {
Write-Host "$logContent" -ForegroundColor $Color
} else {
Write-Host "$logContent"
}
}
# init and vars
Get-HuduModule
Set-HuduInstance
$CONSTANTS=@()
$SMOOSHLABELS=@()
$mapping=@()
$mapfile = "mapping.ps1"
# $CreateAsIPAM=$true
$inspectlayouts = $false
$archivesource = $false
#simpletransfer load datas
write-host "$(if ($allassets -and $null -ne $allassets) {'using existing asset cache'} else {'refreshing asset cache'})"
$allassets = $allassets ?? $(get-huduassets)
write-host "refreshing layouts cache (every time)"
$assetlayouts = get-huduassetlayouts
$totallayouts = $assetlayouts.count
write-host "$(if ($allrelations -and $null -ne $allrelations) {'using existing relations cache'} else {'refreshing relations cache'})"
$allrelations = $allrelations ?? $(Get-HuduRelations)
write-host "adding/calculating addtitional properties for layouts"
foreach ($layout in $assetlayouts) {$layout | Add-Member -NotePropertyName assetsInLayoutCount -NotePropertyValue $($allAssets | Where-Object {$_.asset_layout_id -eq $layout.id}).count -Force}
$assetlayouts=$assetlayouts # | where-object {$_.assetsInLayoutCount -gt 0}
$assetlayouts = $assetlayouts | Sort-Object Name
$usablelayouts = $assetlayouts.count
write-host "$($totallayouts - $usablelayouts) omitted and marked inactive. $totallayouts available layouts."
$choice=Set-LayoutsForTransfer -allLayouts $assetlayouts
$sourceassetlayout = $choice.SourceLayout
$destassetlayout = $choice.DestLayout
$MergeOnMatch = [bool]$("yes" -eq $(Select-ObjectFromList -message "if an asset in source layout $($sourceassetlayout.name) has a Name that matches a Name in dest layout $($destassetlayout.name), should we merge data from source into dest asset (yes) or do something else (no)?" -objects @("yes","no")))
$SkipOnMatch = if ($MergeOnMatch -eq $true) {$false} else {[bool]$("yes" -eq $(Select-ObjectFromList -message "if an asset in source layout $($sourceassetlayout.name) has a Name that matches a Name in dest layout $($destassetlayout.name), should we skip adding source asset into dest (yes) or create both (no)?" -objects @("yes","no")))}
$MergeMode = if ($MergeOnMatch -eq $true) {$(Select-Objectfromlist -message "which merge mode / approach for matching assets in destination?" -objects @('Merge-FillBlanks','Merge-PreferSource','Merge-Concat'))} else {$null}
foreach ($layout in @($sourceassetlayout, $destassetlayout)){
write-host "getting relinkable fields from layout $($layout.name)..."
$layout | Add-Member -NotePropertyName linkables -NotePropertyValue $(Get-RelinkableAssetTagLayoutFields -fromLayoutId $layout.id) -Force
}
if ($(test-path "$mapfile")) {
write-host "backed up $mapfile to $mapfile.old"; Move-Item $mapfile "$mapfile.old" -Force
}
# get fields mapped and ready
$srcfields=@()
$sourceListItemMap = @{}
foreach ($field in $sourceassetlayout.fields | Where-Object {$_.field_type -ne "AssetTag"}) { # assettag fields are carried over as relationships
if ($field.field_type -ieq "ListSelect" -and $null -ne $field.list_id){
$typicalValues = $(Get-HuduLists -id $field.list_id).list_items ?? @()
$sourceListItemMap["$($field.label)"]=$typicalValues.name
$srcfields+=@{label = $field.label; field_type = $field.field_type; list_id=$field.list_id; typicalValues=$typicalValues; required = $($field.required ?? $false)}
} elseif ($field.field_type -ieq 'DropDown' -and -not ([string]::IsNullOrEmpty($field.options))){
$typicalValues = $(Get-NormalizedDropdownOptions $field.options) ?? @()
$srcfields+=@{label = $field.label; field_type = $field.field_type; typicalValues=$typicalValues; required = $($field.required ?? $false)}
} else {
$srcfields+=@{label = $field.label; type = $field.field_type; required = $($field.required ?? $false)}
}
}
$dstfields=@()
foreach ($field in $destassetlayout.fields) {
if ($field.field_type -eq "ListSelect" -and $null -ne $field.list_id){
$dstfields+=@{label = $field.label; field_type = $field.field_type; list_id=$field.list_id; required = $($field.required ?? $false)}
} else {
$dstfields+=@{label = $field.label; field_type = $field.field_type; required = $($field.required ?? $false)}
}
}
foreach ($fields in @(@{name="source"; value=$srcfields}, @{name="dest"; value=$dstfields})) {
$fields.value | convertto-json -depth 66 | out-file "$($fields.name)-fields.json"
}
build-templatemap -destfields $dstfields -mapfile $mapfile
read-host "press enter if you filled in your mapfile, $mapfile"
while ($true) {
if (-not $(test-path "$mapfile")) {
read-host "mapfile not found, please ensure it is in working directory, $mapfile, and press enter to continue"
}
try {
. .\$mapfile
break
} catch {
read-host "your mapfile has error: $_ please update, save, and press enter to try again."
}
}
$sourcedestlabels = @{}; $sourcedestrequired = @{};
$sourcedestStripHTML = @{}; $sourceDestDataType = @{};
$addressMapsByDest = @{}; $ListSelectEquivilencyMaps = @{};
foreach ($entry in $mapping) {
if ($entry.dest_type -eq 'ListSelect' -and -not ([string]::IsNullOrWhiteSpace($entry.from))) {
$parsedMap = @{}
$entry.Mapping.GetEnumerator().Where({$_.Value.whenvalues?.Count -gt 0}).ForEach({
$parsedMap[$_.Key] = $_.Value.whenvalues
})
$ListSelectEquivilencyMaps[$entry.to]=@{Mapping = $parsedMap; list_options=$($entry.Mapping.Keys); list_id=$entry.list_id; add_listitems=$("$($entry.add_listitems)" -ilike "t*" ?? $false)}
$sourcedestlabels[$entry.from] = $entry.to
$sourcedestStripHTML[$entry.from] = [bool]$(@('t','true','y','yes') -contains "$($entry.striphtml ?? "true")".ToLower())
$sourceDestDataType[$entry.from] = 'ListSelect'
continue
} elseif ($entry.dest_type -eq 'AddressData') {
$addressMapsByDest[$entry.to] = $entry.address
$sourcedestrequired[$entry.from] = $false
$sourceDestDataType[$entry.from] = 'AddressData'
$sourcedestlabels[$entry.from] = 'Meta'
continue
}
$sourcedestStripHTML[$entry.from] = [bool]$(@('t','true','y','yes') -contains "$($entry.striphtml ?? "False")".ToLower())
write-host "mapping $($entry.from) to $($entry.to) $(if ($true -eq $sourcedestStripHTML[$entry.from]) {"destination field of $($entry.to) will have HTML stripped."} else {'as-is'})"
$sourcedestlabels[$entry.from] = $entry.to
$sourcedestrequired[$entry.from] = $((Get-CastIfBoolean ($entry.required ?? $false) -allowFuzzy $false) ?? $false)
$sourceDestDataType[$entry.from] = $($entry.dest_type ?? 'Text')
}
$mappingtosmooshed = [bool]$($SMOOSHLABELS.count -gt 0)
$sourceAssets = $($allAssets | Where-Object {$_.asset_layout_id -eq $sourceassetlayout.id})
$destassets = $($allAssets | Where-Object {$_.asset_layout_id -eq $destassetlayout.id})
if ($sourceassets.count -lt 1) { write-host "NO SOURCE ASSETS!"; exit}
read-host "$($($addressMapsByDest.GetEnumerator()).count) Location Types in Target press enter to proceed"
$totalcounts = @{fromablescreated=0; toablescreated=0; assetsarchived=0; assetsmoved=0;
assetsskipped=0; assetsmatched=0; errored=0; sourceassetcount=$sourceassets.count;}
# write-out user-defined infos before start
if ($mappingtosmooshed) {write-host "Smooshing $SMOOSHLABELS => $mappingtosmooshed; $(($mapping | Where-Object { $_.from -eq 'SMOOSH' }).to)"}
if ($CONSTANTS) {
foreach ($c in $CONSTANTS){write-host "Dest Labels containing $($c.to_label) will be given static value from literal $($c.literal) as literal value!"}
} else {write-host "No constants mapped"}
if ($ListSelectEquivilencyMaps.Keys.count -gt 0){Write-host "$($ListSelectEquivilencyMaps.Keys.count) listselect target items mapped for $($ListSelectEquivilencyMaps.Keys -join ",")"}
Write-Host "Smooshing $(if ($excludeHTMLinSMOOSH -and $true -eq $excludeHTMLinSMOOSH) {'using plaintext value-joining'} else {'using traditional HTML value joining'})"
read-host "$($sourceassets.count) source assets and $($destassets.count) dest assets. press enter to proceed"
$sourceassetsIDX=0
foreach ($originalasset in $sourceassets) {
$sourceassetsIDX=$sourceassetsIDX+1
$linkableToAssetInfo = $null; $NewAssetName = $originalasset.name; $matchedMap = $null; $match = $null; $newAsset = $null;
write-host "matching existing assets to asset $sourceassetsIDX of $($sourceassets.count) in destination layout assets ($($destassets.count) total) to determine if overlap"
$match = $destassets | Where-Object { $_.company_id -eq $originalasset.company_id -and $_.name -ieq $originalasset.name } | Select-Object -First 1
if (-not $match -and $originalasset.name.length -gt 6) {
$match = $destassets | where-object {$_.company_id -eq $originalasset.company_id -and ($_.name -ilike "$($originalasset.name)*" -or $_.name -ilike "*$($originalasset.name)")} | Select-Object -First 1
}
$match = $match.asset ?? $match
if ($match -and $null -ne $match -and $null -ne $match.fields) {
$totalcounts.assetsmatched=$totalcounts.assetsmatched+1
if ($true -eq $MergeOnMatch){
write-host "Matched existing asset '$($match.name)' (ID: $($match.id)) in destination layout for source asset '$($originalasset.name)' (ID: $($originalasset.id)) - will compile complete list of fields from both"
$matchedMap = FieldsToLabelValueMap $match.fields
} elseif ($true -eq $SkipOnMatch) {
write-host "match found in dest layout. (#$($totalcounts.assetsmatched)) thus far"
write-host "original: $($($originalasset | ConvertTo-Json -depth 6).ToString())" -ForegroundColor Yellow
write-host "match: $($($match | ConvertTo-Json -depth 6).ToString())" -ForegroundColor Blue
continue
} else {
write-host "match found in dest layout. (#$($totalcounts.assetsmatched)) thus far"
$NewAssetName = "$($originalasset.name) (from layout $($sourceassetlayout.name))"
write-host "overridding name -> $($NewAssetName) and keeping both per user-preference"
}
}
$transformedFields = @()
if ($CONSTANTS -and $CONSTANTS.count -gt 0) {
foreach ($c in $CONSTANTS){
$transformedFields += @{$c.to_label = $c.literal}
}
}
foreach ($field in $originalasset.fields) {
# acquire destination information
$transformedlabel = $sourcedestlabels[$field.label] ?? $null
$destTranslationFieldRequired = $(Get-CastIfBoolean $($sourcedestrequired[$field.label] ?? $false)) ?? $false
$stripHTML = $($sourcedestStripHTML["$($field.label)"] ?? $false)
$destFieldType = $sourceDestDataType["$($field.label)"] ?? 'Text'
# checking basic validity
if (-not $transformedlabel -or $null -eq $transformedlabel) {write-host "no destination mapping for source field $($field.label)"; continue;}
if (-not $field.value -or $null -eq $field.value -or ([string]::IsNullOrWhiteSpace($field.value))) {
write-host "no source value for $($field.label)";
if ($true -eq $destTranslationFieldRequired) {
write-host "no value for REQUIRED $($field.label) => $transformedlabel"
$field.value = $($(read-host "target field $($field.label) => $transformedlabel is required but null, enter value") ?? "None")
} else {
write-host "no value for optional $($field.label) => $transformedlabel"
continue
}
# pre-process listselect source values as huyman-readable
} elseif ($field.value -ilike '*list_id*'){
$precastValue=$field.value;
$listItemId = $null;
$listItemId = $(SafeDecode $field.value).list_ids[0]
$humanValue = $($(get-hudulists).list_items | where-object {$_.id -eq $listItemId} | select-object -first 1).name
$field.value = $humanValue
Write-Host "non-empty source val appears to contain listIDs; Raw val '$($precastValue)' as $destFieldType... $($field.value)" -foregroundColor DarkCyan
}
# handle listselect item-level mappings if present
if ($ListSelectEquivilencyMaps.Keys -contains $transformedlabel) {
$valueEquivilencies = $ListSelectEquivilencyMaps[$transformedlabel]
$mapping = $valueEquivilencies.Mapping
} else {
$valueEquivilencies = $null
}
if (-not $valueEquivilencies -or -not $mapping) {
# destination field-type validation and post-processing
write-host "No list mapping for $($field.label) => $transformedlabel, continuing onto destination-specific ($destFieldType) validation and casting."
if ($true -eq $stripHTML) {
$field.value="$(Remove-HtmlTags -InputString "$($field.value)")"
}
if ($destFieldType -eq "Number"){
$precastValue=$field.value; $field.value = $(Get-CastIfNumeric $field.value) ?? $(Get-CastIfNumeric $($field.value -replace '\D+', ''));
Write-Host "non-empty source val on Number target; Casting '$($precastValue)' as int...$($field.value)"
} elseif ($destFieldType -eq "CheckBox"){
$precastValue=$field.value; $field.value = $(Get-CastIfBoolean $field.value -allowFuzzy $true) ?? $null
Write-Host "non-empty source val on CheckBox/Boolean target; Casting '$($precastValue)' as bool...$($field.value)"
} elseif ($destFieldType -eq "Date"){
$precastValue=$field.value; $field.value = $(Get-CoercedDate -InputDate "$($field.value)" -OutputFormat 'MM/DD/YYYY') ?? $null;
Write-Host "non-empty source val on Date target; Casting '$($precastValue)' as date...$($field.value)"
}
$transformedFields += @{$transformedlabel = $field.value}
} else {
if ([string]::IsNullOrWhiteSpace($field.value)){continue}
# mapping for individual listitems
$result = Set-MappedListSelectItemFromuserMapping `
-Mapping $mapping `
-RawValue $field.value `
-SourceListItemMap $sourceListItemMap `
-FieldLabel $field.label
if ($result.MatchFound) {
Write-Host "$transformedlabel value '$($field.value)' mapped to listselect item '$($result.Key)'"
$transformedFields += @{ $transformedlabel = $result.Key }
} elseif (Get-CastIfBoolean $valueEquivilencies.add_listitems) {
write-host "List item not in range, adding $($field.value) to list id $($valueEquivilencies.list_id)..." -ForegroundColor Yellow
$listCache = Refresh-ListCache
Ensure-HuduListItemByName -ListId $valueEquivilencies.list_id -Name "$($field.value)".Trim() -listNameExistsByListId $listCache
$transformedFields += @{ $transformedlabel = $("$($field.value)".Trim()) }
} else {Write-Host "No value matches for list id $($valueEquivilencies.list_id) from '$($field.value)' / '$($result.Normalized)'; not configured to add list items, so leaving empty."}
}
if ($destFieldType -ilike "Password"){
write-host "$($field.label) => *** [masked password] for value"
} else {
write-host "$($field.label) => $transformedlabel for value $($field.value)"
}
}
# seperate section for meta-mapping address source fields to addressdata target
foreach ($kv in $addressMapsByDest.GetEnumerator()) {
$destLabel = $kv.Key
$addrMap = $kv.Value
$addr1 = Get-FieldValueByLabel $originalasset.fields $addrMap.address_line_1.from
$addr2 = Get-FieldValueByLabel $originalasset.fields $addrMap.address_line_2.from
$city = Get-FieldValueByLabel $originalasset.fields $addrMap.city.from
$state = Get-FieldValueByLabel $originalasset.fields $addrMap.state.from
$zip = Get-FieldValueByLabel $originalasset.fields $addrMap.zip.from
$cntry = Get-FieldValueByLabel $originalasset.fields $addrMap.country_name.from
$state = Normalize-Region $state
$zip = Normalize-Zip $zip
$cntry = Normalize-CountryName $cntry
if ($addr1 -or $addr2 -or $city -or $state -or $zip -or $cntry) {
$NewAddress = [ordered]@{
address_line_1 = $addr1
city = $city
state = $state
zip = $zip
country_name = $cntry
}
if ($addr2) { $NewAddress['address_line_2'] = $addr2 }
$transformedFields += @{ $destLabel = $NewAddress }
}
}
if ($sourceassetlayout.linkables -and $sourceassetlayout.linkables.keys.count -gt 0){
Write-host "Getting linkable items for asset $($originalasset.name) from $($sourceassetlayout.linkables.keys.count) potentially linkable"
$linkableToAssetInfo = Get-RelinkableRelationsForAsset -sourceAsset $originalasset -labelLinkMap $sourceassetlayout.linkables
}
# map custom smooshed fields ( notes, richtext, whatever we smooshed to in map)
if ($true -eq $mappingtosmooshed) {
$valueToAdd="$(Set-SmooshAssetFieldsToField -sourceAsset $originalasset -smooshsource $SMOOSHLABELS -includeBlanks $($includeblanksduringsmoosh ?? $false))"
# if linkables, smoosh in too.
if ($describeRelatedInSmoosh -and $true -eq $describeRelatedInSmoosh){
$describerelated=Get-SmooshedLinkableDescription -linkableObjects $linkableToAssetInfo
$valueToAdd="$describerelated
$valueToAdd"
if ($true -eq $excludeHTMLinSMOOSH){$valueToAdd = Remove-HtmlTags -InputString $valueToAdd }
}
$transformedFields+=@{"$($sourcedestlabels["SMOOSH"])" = $valueToAdd}
}
$newAssetRequest = @{
Name = $NewAssetName ?? $originalasset.name
CompanyId = $originalasset.company_id
AssetLayoutId = $destassetlayout.id
}
if ($null -ne $matchedmap -and $matchedmap.count -gt 0){
write-host "Merging transformed fields with matched existing asset fields..."
$transformedMap = Convert-FieldArrayToMap $transformedFields
$finalMap = Merge-HuduFieldMaps `
-SourceMap $transformedMap -DestMap $matchedMap -LayoutFields $destassetlayout.fields -Mode $mergeMode `
-StampProvenance:$true -SourceStamp "From $($sourceassetlayout.name) " -DestStamp "Existing $($destassetlayout.name) "
$newAssetRequest["Fields"] = LabelValueMapToFields -Map $finalMap -LayoutFields $destassetlayout.fields
$newAssetRequest["Id"] = $match.id
} elseif ($transformedFields -and $transformedFields.count -gt 0){
$newAssetRequest["Fields"]=$transformedFields
write-host $($($transformedFields | convertto-json -depth 5).ToString())
}
# prepare any typical asset properties, falling back to a match if a match is present + configured for merge
$propPairs = @(
@{ Dest = 'PrimarySerial'; Source = 'primary_serial' }
@{ Dest = 'PrimaryMail'; Source = 'primary_mail' }
@{ Dest = 'PrimaryModel'; Source = 'primary_model' }
@{ Dest = 'PrimaryManufacturer'; Source = 'primary_manufacturer' }
)
foreach ($pairing in $propPairs) {
if ($null -ne $matchedMap -and $matchedMap.count -gt 0){
write-host "using matched asset for fallback to common property $($pairing.Source) since merging on match is enabled"
$commonPropValue = $originalAsset.($pairing.Source) ?? $match.($pairing.Source)
} else {
$commonPropValue = $originalAsset.($pairing.Source)
}
if (-not [string]::IsNullOrEmpty("$commonPropValue")) {
Write-Host "using value $commonPropValue from source $($pairing.source)->$($pairing.dest)"
$newAssetRequest[$pairing.Dest] = $commonPropValue
} else {
Write-Host "skipping empty value for common-property, $($pairing.source)"
}
}
# update or create, depending on if we had a match or not
try {
if ($null -ne $newAssetRequest.id -and $newAssetRequest.id -gt 0){
write-host "$($($newAssetRequest | ConvertTo-Json -depth 66).ToString())"
$newAsset = $(set-huduasset @newAssetRequest)
$newAsset = $newAsset.asset ?? $newAsset
write-host "updated asset $($newAsset.id)"
} else {
write-host "$($($newAssetRequest | ConvertTo-Json -depth 66).ToString())"
$newAsset = $(new-huduasset @newAssetRequest)
$newAsset = $newAsset.asset ?? $newAsset
write-host "Created asset $($newAsset.id)"
}
} catch {
Write-ErrorObjectsToFile -ErrorObject @{Err=$_; request=$newAssetRequest} -Name "$($newAssetRequest.name)$(if ($null -ne $newAssetRequest.id -and $newAssetRequest.id -gt 0) {"-update-$($newAssetRequest.id)"} else {"-create"})"
continue
}
if (-not $newAsset -or $null -eq $newAsset) {
Write-ErrorObjectsToFile -ErrorObject $newAssetRequest -Name "NC-$($newAssetRequest.name)"
$totalcounts.errored=$totalcounts.errored+1
continue
}
if ($null -ne $newAssetRequest.id -and $newAssetRequest.id -gt 0){
write-host "updated asset $($newasset.id), no need to re-relate or archive items outside of previous asset-tags"
} else {
# archive new asset if original was archived
if ($originalasset.archived -eq $true) {
Set-HuduAssetArchive -CompanyId $newAsset.company_id -Id $newAsset.id -Archive $true
$totalcounts.assetsarchived=$totalcounts.assetsarchived+1
}
# archive source asset if configured to do so
if ($archivesource -eq $true) {
Set-HuduAssetArchive -CompanyId $originalasset.company_id -Id $originalasset.id -Archive $true
$totalcounts.assetsarchived=$totalcounts.assetsarchived+1
}
$totalcounts.assetsmoved=$totalcounts.assetsmoved+1
write-host "created asset $($newasset.id), adding relations now."
# add relations
$sourceToables = $($($allrelations | where-object {$_.toable_type -eq 'Asset' -and $originalasset.id -eq $_.toable_id }) ?? @())
write-host "$($sourceToables.count) toable relations"
$sourceFromables = $($($allrelations | where-object {$_.fromable_type -eq 'Asset' -and $originalasset.id -eq $_.fromable_id }) ?? @())
write-host "$($sourceFromables.count) fromable relations"
$relationsTo = $sourceToables | Where-Object { $_.toable_id -eq $originalasset.id }
if (get-command -name Set-HapiErrorsDirectory -ErrorAction SilentlyContinue){try {Set-HapiErrorsDirectory -skipRetry $true} catch {}}
foreach ($rel in $relationsTo) {
try {
$newToable=New-HuduRelation -FromableType $rel.fromable_type -FromableId $rel.fromable_id -ToableType "Asset" -ToableId $newAsset.id
write-host "created toable rel $($newToable.id)"
$totalcounts.toablescreated= if ($newToable) {$totalcounts.toablescreated+1} else {$totalcounts.toablescreated}
} catch {
Write-ErrorObjectsToFile -ErrorObject @{Err= $_; From = $relationsFrom; To=$relationsTo} -Name "NCREL-TOABLE-$($newasset.name)"
}
}
$relationsFrom = $sourceFromables | Where-Object { $_.fromable_id -eq $originalasset.id }
foreach ($rel in $relationsFrom) {
try {
$newFromable=New-HuduRelation -FromableType "Asset" -FromableId $newAsset.id -ToableType $rel.toable_type -ToableId $rel.toable_id
write-host "created fromable rel $($newFromable.id)"
$totalcounts.fromablescreated= if ($newFromable) {$totalcounts.fromablescreated+1} else {$totalcounts.fromablescreated}
} catch {
Write-ErrorObjectsToFile -ErrorObject @{Err= $_; From = $relationsFrom; To=$relationsTo} -Name "NCREL-FROMABLE-$($newasset.name)"
}
}
}
# add assettag linking regardless of match/merge or made assets
if ($linkableToAssetInfo -and $linkableToAssetInfo.count -gt 0){
if (get-command -name Set-HapiErrorsDirectory -ErrorAction SilentlyContinue){try {Set-HapiErrorsDirectory -skipRetry $true} catch {}}
write-host "Asset has external asset links, relinking $($linkableToAssetInfo.count) for $($originalasset.name)"
foreach ($linkableToAsset in $linkableToAssetInfo) {
$linkedAsset=$linkableToAsset.LinkedAsset
if (-not $linkableToAsset.LinkedAsset) {continue}
try {
$newToable=New-HuduRelation -FromableType 'Asset' -ToableType "Asset" -FromableId $LinkedAsset.id -ToableID $newAsset.id
$totalcounts.toablescreated= if ($newToable) {$totalcounts.toablescreated+1} else {$totalcounts.toablescreated}
write-host "created asset-toable rel $($newToable.id)"
} catch {
$totalcounts.errored=$totalcounts.errored+1
Write-ErrorObjectsToFile -ErrorObject @{Err = $_; From = $relationsFrom; To=$relationsTo} -Name "NCREL-AL-$($newasset.name)"
}
}
}
if (get-command -name Set-HapiErrorsDirectory -ErrorAction SilentlyContinue){try {Set-HapiErrorsDirectory -skipRetry $false} catch {}}
}
Write-host "wrap-up" -ForegroundColor cyan
$newlayoutname = $null
if ("yes" -eq $(Select-ObjectFromList -objects @("yes","no") -message "would you like to rename source layout ($($sourceassetlayout.name))" -allowNull $false)){
$newlayoutname = read-host "what is the new name for $($sourceassetlayout.name)"
}
if ([string]::IsNullOrWhiteSpace($newlayoutname)) {$newlayoutname = $sourceassetlayout.name}
if ("yes" -eq $(Select-ObjectFromList -objects @("yes","no") -message "would you like to archive source layout's assets? ($($sourceassets.count) total)" -allowNull $false)){
$setsourceassetsarchived = $true
}
if ($newlayoutname -ne $sourceassetlayout.name){
Set-HuduAssetLayout -id $sourceassetlayout.id -Name $newlayoutname
}
if ($true -eq $setsourceassetsarchived) {
foreach ($originalasset in $sourceassets) {
$result=Set-HuduAssetArchive -id $originalasset.id -CompanyId $originalasset.company_id -archive $true
$totalcounts.assetsarchived=$(if ($result) {$totalcounts.assetsarchived+1} else {$totalcounts.assetsarchived})
}
}
foreach ($entry in $totalcounts.GetEnumerator()) {
Write-Host "$($entry.Key): $($entry.Value)" -ForegroundColor DarkCyan
}