mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 10:55:38 +02:00
429 lines
18 KiB
PowerShell
429 lines
18 KiB
PowerShell
# Avalonia port of the Import dialog from UI/WPF/Extensions/IntuneManagerUI.ps1
|
|
# (Show-IntuneManagerImportForm + supporting helpers). New file because
|
|
# Import is a self-contained subsystem and the WPF original sits in the
|
|
# giant CoreUI/IntuneManagerUI files we're trying not to grow further
|
|
# (architecture rule R9).
|
|
#
|
|
# CLOSURE-FREE design (after 2026-05-30 fix): PowerShell's `.GetNewClosure()`
|
|
# puts a scriptblock into a brand-new DynamicModule that has no access to
|
|
# IntuneManagement's module-private functions (Save-SettingStoreValue,
|
|
# Get-MigrationTableInfo, Write-Log, ...). `ConvertTo-AvaloniaEventScriptBlock`
|
|
# then calls NewBoundScriptBlock which strips the closure entirely.
|
|
# Both effects = click handlers crash with "property not found" /
|
|
# "term not recognized". To avoid this, the form's state lives in
|
|
# `$script:_imImportFormState`, the helper logic is exposed as real
|
|
# module-scope functions (`Invoke-IntuneManagerImportLoad`,
|
|
# `Update-IntuneManagerImportMatch`), and click handlers are plain
|
|
# scriptblocks (no .GetNewClosure()) that reference the script-scope state
|
|
# and call the helper functions by name.
|
|
#
|
|
# The match-resolution helpers (Resolve-IntuneImportUpdateTarget,
|
|
# New-IntuneImportMatchResult, Get-IntuneImportPolicyReferenceTokens,
|
|
# Normalize-IntuneImportPolicyName) live in Internal/IntuneManager.ps1 and
|
|
# are shared with the WPF tree.
|
|
|
|
function Update-IntuneManagerImportMatch
|
|
{
|
|
# Operates on $script:_imImportFormState. Recomputes ImportAction /
|
|
# ImportMatch / ImportMatchStrategy / ImportTarget on every visible
|
|
# row, then forces a DataGrid refresh (no INotifyPropertyChanged on
|
|
# IntuneImportRowItem). Called from cbImportType selection-changed and
|
|
# from the load helper after a fresh load.
|
|
$st = $script:_imImportFormState
|
|
if (-not $st -or -not $st.DG -or -not $st.DG.ItemsSource) { return }
|
|
|
|
$importType = [string]$st.Settings.ImportType
|
|
$sameTenant = [bool]$st.Settings.SameTenant
|
|
$existing = @()
|
|
if ($script:dgIntuneManagerObjects) {
|
|
$existing = @($script:dgIntuneManagerObjects.ItemsSource | ForEach-Object {
|
|
if ($_ -and $_.Source) { $_.Source } else { $_ }
|
|
} | Where-Object { $_ })
|
|
}
|
|
|
|
foreach ($row in @($st.DG.ItemsSource)) {
|
|
if (-not $row -or -not $row.Source) { continue }
|
|
$match = Resolve-IntuneImportUpdateTarget -ImportPolicy $row.Source -ExistingPolicies $existing -SameTenant $sameTenant -ImportType $importType
|
|
$row.ImportAction = [string]$match.Action
|
|
$row.ImportMatch = [string]$match.Message
|
|
$row.ImportMatchStrategy = [string]$match.Strategy
|
|
$row.ImportTarget = $match.Target
|
|
}
|
|
|
|
$current = @($st.DG.ItemsSource)
|
|
$st.DG.ItemsSource = $null
|
|
$st.DG.ItemsSource = $current
|
|
}
|
|
|
|
function Invoke-IntuneManagerImportLoad
|
|
{
|
|
# Reads $script:_imImportFormState. Loads files from $Folder into
|
|
# the form's DataGrid + populates migration-table info. Surface any
|
|
# error via message box so the user sees the cause; the previous
|
|
# silent-return path swallowed Get-MigrationTableInfo / Get-PoliciesFromFolder
|
|
# failures.
|
|
param([string]$Folder, [bool]$KeepStatus, [switch]$Quiet)
|
|
|
|
$st = $script:_imImportFormState
|
|
if (-not $st) { return }
|
|
$ui = $script:UIProvider
|
|
|
|
if (-not $Folder) { return }
|
|
Write-Status "Get policy objects from $Folder"
|
|
|
|
try { $Folder = Expand-FileName $Folder } catch { }
|
|
|
|
if (-not [IO.Directory]::Exists($Folder)) {
|
|
if (-not $KeepStatus) { Write-Status "" }
|
|
# -Quiet is used for the seeded-folder load that runs BEFORE the dialog
|
|
# is shown: a popup there appears out of nowhere with no dialog behind
|
|
# it. An explicit Browse/Get files still warns.
|
|
if (-not $Quiet) {
|
|
$ui.ShowMessageBox("Folder does not exist:`n$Folder", "Import", "OK", "Warning")
|
|
} else {
|
|
Write-Log "Import: seeded folder does not exist: $Folder" 2
|
|
}
|
|
return
|
|
}
|
|
|
|
try {
|
|
$params = @{}
|
|
$curPolicyTypes = Get-IntuneManagerSelectedPolicyTypes
|
|
$subFolders = @()
|
|
foreach ($pt in $curPolicyTypes) {
|
|
if ([IO.Directory]::Exists([IO.Path]::Combine($Folder, $pt.Folder))) {
|
|
$subFolders += $pt.Folder
|
|
}
|
|
}
|
|
if ($subFolders.Count -gt 0) { $params['SubFolders'] = $subFolders }
|
|
if ($curPolicyTypes) { $params['PolicyTypes'] = $curPolicyTypes }
|
|
|
|
# Collect into a typed List + sort in place. Routing through
|
|
# `Sort-Object ObjectName` would stamp a PSObject ETS wrapper on each
|
|
# IntuneImportRowItem and Avalonia's DataGrid text-column binder
|
|
# reflects on the runtime type and renders blank cells against the
|
|
# wrapper (see [[avalonia-binding-needs-clr-types]]).
|
|
$rows = [System.Collections.Generic.List[IntuneImportRowItem]]::new()
|
|
foreach ($policy in @(Get-PoliciesFromFolder $Folder @params)) {
|
|
$obj = [PSCustomObject]$policy
|
|
$row = [IntuneImportRowItem]::new()
|
|
$row.Selected = $true
|
|
$row.Source = $obj
|
|
$row.ObjectName = [string]$obj.Name
|
|
$row.PolicyType = [string]$obj.PolicyName
|
|
$row.PolicyBase = [string]$obj.PolicyBaseName
|
|
$row.Platform = [string]$obj.Platform
|
|
$row.FileName = if ($obj.FileInfo) { [string]$obj.FileInfo.Name } else { '' }
|
|
[void]$rows.Add($row)
|
|
}
|
|
$rows.Sort([Comparison[IntuneImportRowItem]]{
|
|
param($a, $b)
|
|
[string]::Compare($a.ObjectName, $b.ObjectName, [StringComparison]::OrdinalIgnoreCase)
|
|
})
|
|
|
|
if ($st.DG) { $st.DG.ItemsSource = $rows }
|
|
|
|
Save-SettingStoreValue "" "LastUsedFullPath" $Folder
|
|
|
|
$migrationInfo, $sameTenant = Get-MigrationTableInfo $Folder $script:organizationId
|
|
$st.Settings.SameTenant = [bool]$sameTenant
|
|
if ($st.LblMigInfo) { $st.LblMigInfo.Text = [string]$migrationInfo }
|
|
if ($st.ChkRepl) {
|
|
$st.ChkRepl.IsEnabled = (-not $sameTenant)
|
|
$st.ChkRepl.IsChecked = (-not $sameTenant)
|
|
$st.Settings.ReplaceDependencyIDs = (-not $sameTenant)
|
|
}
|
|
|
|
Update-IntuneManagerImportMatch
|
|
} catch {
|
|
# Log the internals; show the user only the message. The raw
|
|
# script/line dump had no WPF counterpart.
|
|
Write-LogError "Invoke-IntuneManagerImportLoad failed" $_.Exception
|
|
$ui.ShowMessageBox("Could not read the import folder:`n`n$($_.Exception.Message)", "Import error", "OK", "Error")
|
|
}
|
|
|
|
if (-not $KeepStatus) { Write-Status "" }
|
|
}
|
|
|
|
function Show-IntuneManagerImportForm
|
|
{
|
|
$ui = $script:UIProvider
|
|
$policyTypes = Get-IntuneManagerSelectedPolicyTypes
|
|
|
|
if (($policyTypes | Measure-Object).Count -eq 0) {
|
|
$ui.ShowMessageBox("No object types selected.", "Error", "OK", "Error")
|
|
return
|
|
}
|
|
|
|
$importForm = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/ImportForm.axaml'))
|
|
if (-not $importForm) { return }
|
|
|
|
Write-Status "Load import form"
|
|
|
|
$hostType = Get-AvaloniaHost
|
|
$txtFolder = $hostType::FindByName($importForm, 'txtImportFolder')
|
|
$browseFolder = $hostType::FindByName($importForm, 'browseImportFolder')
|
|
$lblMigInfo = $hostType::FindByName($importForm, 'lblMigrationTableInfo')
|
|
$chkScopes = $hostType::FindByName($importForm, 'chkImportScopes')
|
|
$chkAssign = $hostType::FindByName($importForm, 'chkImportAssignments')
|
|
$chkReplaceDeps = $hostType::FindByName($importForm, 'chkReplaceDependencyIDs')
|
|
$cbImportType = $hostType::FindByName($importForm, 'cbImportType')
|
|
$dgImport = $hostType::FindByName($importForm, 'dgObjectsToImport')
|
|
$btnGetFiles = $hostType::FindByName($importForm, 'btnGetFiles')
|
|
$btnImport = $hostType::FindByName($importForm, 'btnImportSelected')
|
|
$btnCancel = $hostType::FindByName($importForm, 'btnCancel')
|
|
|
|
# Settings instance + per-type extras (data-side hooks).
|
|
$importSettings = [IntuneManagerImportSettings]::new()
|
|
$policyTypes | Add-IntuneManagerImportProperties -ImportSettings $importSettings
|
|
$policyTypes | Add-IntuneManagerImportUIExtensions -Form $importForm
|
|
|
|
# Prefer the last-used full path, but if it points into a per-type subfolder
|
|
# of the currently-selected policy types, climb up one level so the parent
|
|
# is used (matches WPF heuristic — recommended behaviour for multi-type
|
|
# imports).
|
|
$path = Get-SettingStoreValue "" "LastUsedFullPath"
|
|
if ($path) {
|
|
try {
|
|
$di = [IO.DirectoryInfo]$path
|
|
if ($policyTypes | Where-Object { $_.Folder -eq $di.Name }) {
|
|
$path = $di.Parent.FullName
|
|
}
|
|
if (-not [IO.Directory]::Exists($path)) {
|
|
$path = Get-SettingStoreValue "" "LastUsedRoot"
|
|
}
|
|
} catch { }
|
|
if ($path) { $importSettings.ImportFolder = $path }
|
|
}
|
|
|
|
if ($txtFolder) { $txtFolder.Text = [string]$importSettings.ImportFolder }
|
|
if ($chkScopes) { $chkScopes.IsChecked = [bool]$importSettings.ImportScopeTags }
|
|
if ($chkAssign) { $chkAssign.IsChecked = [bool]$importSettings.ImportAssignments }
|
|
if ($chkReplaceDeps) { $chkReplaceDeps.IsChecked = [bool]$importSettings.ReplaceDependencyIDs }
|
|
|
|
# Import type ComboBox: NameValueObject -> SettingsListItem.
|
|
$importTypeItems = @()
|
|
foreach ($opt in $script:IntuneManagerImportOptions) {
|
|
$importTypeItems += [SettingsListItem]@{
|
|
Name = [string]$opt.Name
|
|
Value = [string]$opt.Value
|
|
}
|
|
}
|
|
if ($cbImportType) {
|
|
$cbImportType.ItemsSource = $importTypeItems
|
|
$current = $importTypeItems | Where-Object { $_.Value -eq [string]$importSettings.ImportType } | Select-Object -First 1
|
|
if (-not $current -and $importTypeItems.Count -gt 0) { $current = $importTypeItems[0] }
|
|
if ($current) { $cbImportType.SelectedItem = $current }
|
|
}
|
|
|
|
# Module-scope state container. Avoids .GetNewClosure() in event
|
|
# handlers (see file-level comment). Cleared when the form closes.
|
|
$script:_imImportFormState = @{
|
|
Settings = $importSettings
|
|
Form = $importForm
|
|
DG = $dgImport
|
|
LblMigInfo = $lblMigInfo
|
|
ChkRepl = $chkReplaceDeps
|
|
TxtFolder = $txtFolder
|
|
Confirmed = $false
|
|
}
|
|
|
|
# --- Event wiring (NO closures — references $script:_imImportFormState by name) ---
|
|
|
|
if ($browseFolder) {
|
|
$browseFolder.add_Click({
|
|
$st = $script:_imImportFormState
|
|
$ui2 = $script:UIProvider
|
|
if (-not $st) { return }
|
|
$start = if ($st.TxtFolder) { [string]$st.TxtFolder.Text } else { $null }
|
|
$folder = $ui2.ShowFolderPicker($start, 'Select root folder for import')
|
|
if ($folder) {
|
|
if ($st.TxtFolder) { $st.TxtFolder.Text = $folder }
|
|
$st.Settings.ImportFolder = $folder
|
|
Invoke-IntuneManagerImportLoad -Folder $folder -KeepStatus $false
|
|
}
|
|
})
|
|
}
|
|
|
|
if ($btnGetFiles) {
|
|
$btnGetFiles.add_Click({
|
|
$st = $script:_imImportFormState
|
|
if (-not $st) { return }
|
|
$folder = if ($st.TxtFolder) { [string]$st.TxtFolder.Text } else { $st.Settings.ImportFolder }
|
|
$st.Settings.ImportFolder = $folder
|
|
Invoke-IntuneManagerImportLoad -Folder $folder -KeepStatus $false
|
|
})
|
|
}
|
|
|
|
if ($cbImportType) {
|
|
$cbImportType.add_SelectionChanged({
|
|
param($s, $e)
|
|
$st = $script:_imImportFormState
|
|
if (-not $st) { return }
|
|
if ($s.SelectedItem) {
|
|
$st.Settings.ImportType = [string]$s.SelectedItem.Value
|
|
}
|
|
Update-IntuneManagerImportMatch
|
|
})
|
|
}
|
|
|
|
# Two-way mirror for the simple checkboxes.
|
|
if ($chkScopes) {
|
|
$chkScopes.add_IsCheckedChanged({
|
|
param($s, $e)
|
|
$st = $script:_imImportFormState
|
|
if ($st) { $st.Settings.ImportScopeTags = [bool]$s.IsChecked }
|
|
})
|
|
}
|
|
if ($chkAssign) {
|
|
$chkAssign.add_IsCheckedChanged({
|
|
param($s, $e)
|
|
$st = $script:_imImportFormState
|
|
if ($st) { $st.Settings.ImportAssignments = [bool]$s.IsChecked }
|
|
})
|
|
}
|
|
if ($chkReplaceDeps) {
|
|
$chkReplaceDeps.add_IsCheckedChanged({
|
|
param($s, $e)
|
|
$st = $script:_imImportFormState
|
|
if ($st) { $st.Settings.ReplaceDependencyIDs = [bool]$s.IsChecked }
|
|
})
|
|
}
|
|
|
|
# Select/deselect-all moved into the DataGrid column header.
|
|
Initialize-AvaloniaGridSelectAllHeader -Grid $dgImport -BindingProperty 'Selected' -InitiallyChecked $true | Out-Null
|
|
|
|
if ($btnImport) {
|
|
$btnImport.add_Click({
|
|
# Run the import while the form state is still live, then close + clear.
|
|
$st = $script:_imImportFormState
|
|
if (-not $st) { return }
|
|
try { Invoke-IntuneManagerImportExecute }
|
|
catch { Write-LogError "Import execution failed" $_.Exception }
|
|
Show-ModalObject
|
|
$script:_imImportFormState = $null
|
|
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
|
|
})
|
|
}
|
|
if ($btnCancel) {
|
|
$btnCancel.add_Click({
|
|
Show-ModalObject
|
|
$script:_imImportFormState = $null
|
|
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
|
|
})
|
|
}
|
|
|
|
$importForm.add_KeyDown({
|
|
param($s, $e)
|
|
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
|
|
Show-ModalObject
|
|
$script:_imImportFormState = $null
|
|
$e.Handled = $true
|
|
}
|
|
})
|
|
|
|
# Initial load against the seeded folder (if any). KeepStatus=true so
|
|
# Write-Status "" runs after the modal opens, not before.
|
|
if ($importSettings.ImportFolder) {
|
|
Invoke-IntuneManagerImportLoad -Folder $importSettings.ImportFolder -KeepStatus $true -Quiet
|
|
}
|
|
|
|
# ShowModalForm is non-blocking, so the import cannot run after it returns
|
|
# (the handlers fire later, on the dispatcher loop). It runs inside the
|
|
# btnImport handler via Invoke-IntuneManagerImportExecute while state is live.
|
|
$ui.ShowModalForm("Import objects", $importForm, $true)
|
|
Write-Status ""
|
|
}
|
|
|
|
function Invoke-IntuneManagerImportExecute
|
|
{
|
|
# Runs the actual import for the live $script:_imImportFormState. Called from
|
|
# the Import button handler (not after ShowModalForm, which does not block).
|
|
$st = $script:_imImportFormState
|
|
if (-not $st) { return }
|
|
$ui = $script:UIProvider
|
|
|
|
Write-Status "Import selected objects"
|
|
# Honour the ClearCacheBeforeExportImport setting (bit 8 = manual import).
|
|
Invoke-GraphCacheClearBeforeOperation -Operation ManualImport
|
|
|
|
$importType = [string]$st.Settings.ImportType
|
|
# Persist the per-import toggles so the import pipeline (Get-SettingValue) honours them.
|
|
$st.Settings.Save()
|
|
$rows = @()
|
|
if ($st.DG -and $st.DG.ItemsSource) {
|
|
$rows = @($st.DG.ItemsSource | Where-Object { $_ -and $_.Selected })
|
|
}
|
|
|
|
if ($rows.Count -eq 0) {
|
|
Write-Log "No files selected for import" 2
|
|
Write-Status ""
|
|
return
|
|
}
|
|
|
|
# Re-resolve match info up to the moment of import — the user may have
|
|
# toggled rows or changed ImportType after the last refresh. Put state
|
|
# back briefly so the helper can read it.
|
|
$script:_imImportFormState = $st
|
|
Update-IntuneManagerImportMatch
|
|
$script:_imImportFormState = $null
|
|
|
|
$existing = @()
|
|
if ($script:dgIntuneManagerObjects) {
|
|
$existing = @($script:dgIntuneManagerObjects.ItemsSource | ForEach-Object {
|
|
if ($_ -and $_.Source) { $_.Source } else { $_ }
|
|
} | Where-Object { $_ })
|
|
}
|
|
|
|
$policiesToImport = @()
|
|
$updatedPolicies = @()
|
|
|
|
foreach ($row in $rows) {
|
|
$importPolicy = $row.Source
|
|
if (-not $importPolicy) { continue }
|
|
|
|
$match = Resolve-IntuneImportUpdateTarget -ImportPolicy $importPolicy -ExistingPolicies $existing -SameTenant $st.Settings.SameTenant -ImportType $importType
|
|
|
|
if ($match.Action -eq "Update" -and $importType -eq "update") {
|
|
try {
|
|
$updated = $importPolicy.UpdateObject($match.Target, (Get-DefaultTokenId))
|
|
if ($updated) { $updatedPolicies += $updated }
|
|
} catch { Write-LogError "UpdateObject failed for $($importPolicy.Name)" $_.Exception }
|
|
}
|
|
elseif ($match.Action -eq "Replace") {
|
|
try {
|
|
$replaced = Invoke-IntuneImportReplace -ImportPolicy $importPolicy -Target $match.Target -ImportType $importType
|
|
if ($replaced) { $updatedPolicies += $replaced }
|
|
} catch { Write-LogError "Replace failed for $($importPolicy.Name)" $_.Exception }
|
|
}
|
|
elseif ($match.Action -eq "Ambiguous") {
|
|
Write-Log "Skip import/update for $($importPolicy.Name) ($($importPolicy.PolicyName)): $($match.Message)" 2
|
|
}
|
|
elseif ($match.Action -eq "Skip") {
|
|
Write-Log "Skip import/update for $($importPolicy.Name) ($($importPolicy.PolicyName)): $($match.Message)"
|
|
}
|
|
else {
|
|
$policiesToImport += $importPolicy
|
|
}
|
|
}
|
|
|
|
$importedPolicies = @()
|
|
if ($policiesToImport.Count -gt 0) {
|
|
try {
|
|
$importedPolicies = @($policiesToImport | Import-GraphPolicy)
|
|
} catch {
|
|
Write-LogError "Import-GraphPolicy failed" $_.Exception
|
|
$ui.ShowMessageBox("Import failed: $($_.Exception.Message)", "Error", "OK", "Error")
|
|
Write-Status ""
|
|
return
|
|
}
|
|
}
|
|
|
|
if ($importedPolicies.Count -gt 0 -or $updatedPolicies.Count -gt 0) {
|
|
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject | Out-Null
|
|
}
|
|
|
|
Write-Status ""
|
|
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
|
|
}
|