# Avalonia port of the Bulk Import dialog from # UI/WPF/Extensions/IntuneManagerUI.ps1 (Show-GraphBulkImportForm). # New file because Bulk Import is a self-contained subsystem and the WPF # original sits in the giant IntuneManagerUI file we're trying not to grow # further (architecture rule R9). # # Slice 4b: core Bulk Import form. Per-policy-type Add-IntuneManagerImportUIExtensions # wrapper is invoked but currently a no-op — no class extension defines # AddUIImportExtensions in either tree. # # Thin caller of the public Start-GraphBulkImport driver (R10). The old # "Save settings for batch job" button was removed 2026-06-12 per user # decision — scheduled runs call the driver directly. function Update-BulkImportObjectList { if (-not $script:dgBulkImportObjects) { return } $rows = [System.Collections.Generic.List[BulkImportRowItem]]::new() $sortedGroups = $script:bulkImportImportableGroups | Sort-Object Title foreach ($intuneGroup in $sortedGroups) { $defaultSelected = ?? $intuneGroup.BulkImport $true $rows.Add([BulkImportRowItem]@{ Title = [string]$intuneGroup.Title Selected = [bool]$defaultSelected ObjectGroup = $intuneGroup }) } $script:bulkImportRows = @($rows) $script:dgBulkImportObjects.ItemsSource = $script:bulkImportRows } function Show-GraphBulkImportForm { $ui = $script:UIProvider $form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/BulkImportForm.axaml')) if (-not $form) { return } $hostType = Get-AvaloniaHost $script:dgBulkImportObjects = $hostType::FindByName($form, 'dgObjectsToImport') $txtPath = $hostType::FindByName($form, 'txtImportPath') $btnBrowse = $hostType::FindByName($form, 'browseImportPath') $txtFilter = $hostType::FindByName($form, 'txtImportNameFilter') $lblMigInfo = $hostType::FindByName($form, 'lblMigrationTableInfo') $chkScopes = $hostType::FindByName($form, 'chkImportScopes') $chkAssign = $hostType::FindByName($form, 'chkImportAssignments') $chkReplaceDeps = $hostType::FindByName($form, 'chkReplaceDependencyIDs') $cbImportType = $hostType::FindByName($form, 'cbImportType') $btnImport = $hostType::FindByName($form, 'btnImport') $btnClose = $hostType::FindByName($form, 'btnClose') $importSettings = [IntuneManagerImportSettings]::new() # Handlers lose function-local captures when module-bound, so every # control they touch lives in module scope (Bulk Compare pattern). $script:_bulkImportFormState = [ordered]@{ TxtPath = $txtPath TxtFilter = $txtFilter ChkScopes = $chkScopes ChkAssign = $chkAssign ChkReplaceDeps = $chkReplaceDeps LblMigInfo = $lblMigInfo CbImportType = $cbImportType ImportSettings = $importSettings } $script:bulkImportImportableGroups = @($script:IntuneGroups | Where-Object { $_.Title -and (-not ($_.ShowButtons -is [Object[]]) -or $_.ShowButtons -contains "Import") }) # Per-type UI extensions (no-op today — no class defines # AddUIImportExtensions — but matches the WPF call site so future # extensions hook in automatically). foreach ($intuneGroup in $script:bulkImportImportableGroups) { $intuneGroup.PolicyTypes | Add-IntuneManagerImportUIExtensions -Form $form } # Push current values into controls. IntuneManagerImportSettings has no # INotifyPropertyChanged so two-way binding would render but never # round-trip — read back imperatively on Import click. if ($txtPath) { $txtPath.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 (same convention # as Slice 3e single-policy import). $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 } } Update-BulkImportObjectList # Migration-info refresh: reads the Groups/MigrationTable.json under the # given folder, sets sameTenant -> ReplaceDependencyIDs default. Same # behaviour as Slice 3e's loadFromFolder, minus the policy-file scan. # Runs here (not earlier) because it reads the module-scope state. if ($importSettings.ImportFolder) { Update-BulkImportMigrationInfo -Folder $importSettings.ImportFolder } if ($btnBrowse) { $btnBrowse.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_bulkImportFormState if (-not $st) { return } $folder = $script:UIProvider.ShowFolderPicker(([string]$st.TxtPath.Text), 'Select root folder for import') if ($folder) { $st.ImportSettings.ImportFolder = $folder if ($st.TxtPath) { $st.TxtPath.Text = $folder } Update-BulkImportMigrationInfo -Folder $folder } })) } if ($cbImportType) { $cbImportType.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock { param($s, $e) $st = $script:_bulkImportFormState if ($st -and $s.SelectedItem) { $st.ImportSettings.ImportType = [string]$s.SelectedItem.Value } })) } # Select/deselect-all moved into the DataGrid column header. Initialize-AvaloniaGridSelectAllHeader -Grid $script:dgBulkImportObjects -BindingProperty 'Selected' -InitiallyChecked $true | Out-Null if ($btnImport) { $btnImport.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_bulkImportFormState if (-not $st) { return } $importSettings = $st.ImportSettings $importFolder = if ($st.TxtPath) { [string]$st.TxtPath.Text } else { '' } if ($importFolder) { $importFolder = $importFolder.Trim().Trim('"').Trim("'") } try { $importFolder = Expand-FileName $importFolder } catch { } if (-not [IO.Directory]::Exists($importFolder)) { $ui.ShowMessageBox("Import folder not found:`n$importFolder", "Bulk Import", "OK", "Error") | Out-Null return } $importSettings.ImportFolder = $importFolder if ($st.ChkScopes) { $importSettings.ImportScopeTags = [bool]$st.ChkScopes.IsChecked } if ($st.ChkAssign) { $importSettings.ImportAssignments = [bool]$st.ChkAssign.IsChecked } if ($st.ChkReplaceDeps) { $importSettings.ReplaceDependencyIDs = [bool]$st.ChkReplaceDeps.IsChecked } $selectedGroups = @($script:bulkImportRows | Where-Object { $_.Selected -and $_.ObjectGroup }) if ($selectedGroups.Count -eq 0) { $ui.ShowMessageBox("No object types selected.", "Bulk Import", "OK", "Warning") | Out-Null return } Save-SettingStoreValue "" "LastUsedRoot" $importFolder $nameFilter = if ($st.TxtFilter) { ([string]$st.TxtFilter.Text).Trim() } else { '' } # The UI is a thin caller of the public driver (R10) — the same # import runs headless via Start-GraphBulkImport, which also # persists the checkbox settings (the import pipeline reads them # via Get-SettingValue) and honours ClearCacheBeforeExportImport. Write-Status "Bulk import" -Block $totalImported = 0 try { $summary = Start-GraphBulkImport -ImportFolder $importFolder -Filter $nameFilter ` -PolicyGroup @($selectedGroups | ForEach-Object { $_.ObjectGroup.ID }) ` -ImportAssignments $importSettings.ImportAssignments ` -ImportScopeTags $importSettings.ImportScopeTags ` -ReplaceDependencyIDs $importSettings.ReplaceDependencyIDs ` -ImportType $importSettings.ImportType $totalImported = $summary.Imported } catch { Write-LogError "Bulk import failed" $_.Exception $ui.ShowMessageBox("Bulk import failed:`n`n$($_.Exception.Message)", "Bulk Import", "OK", "Error") | Out-Null } Write-Status "" $unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors if ($totalImported -eq 0) { $msg = "No objects were imported.`nVerify the import folder and exported files." if ($unknownNote) { $msg += "`n`n$unknownNote" } $ui.ShowMessageBox($msg, "Bulk Import", "OK", "Warning") | Out-Null } else { $msg = ("Imported {0} object(s) from:`n{1}" -f $totalImported, $importFolder) if ($unknownNote) { $msg += "`n`n$unknownNote" } $ui.ShowMessageBox($msg, "Bulk Import", "OK", "Information") | Out-Null if ($script:IntuneManagerSelectedObject) { Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject | Out-Null } } $script:dgBulkImportObjects = $null $script:bulkImportRows = $null $script:_bulkImportFormState = $null Show-ModalObject })) } if ($btnClose) { $btnClose.add_Click((ConvertTo-AvaloniaEventScriptBlock { $script:dgBulkImportObjects = $null $script:bulkImportRows = $null $script:_bulkImportFormState = $null Show-ModalObject })) } $form.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock { param($s, $e) if ($e.Key -eq [Avalonia.Input.Key]::Escape) { $script:dgBulkImportObjects = $null $script:bulkImportRows = $null $script:_bulkImportFormState = $null Show-ModalObject $e.Handled = $true } })) $ui.ShowModalForm("Bulk Import", $form, $true) } function Update-BulkImportMigrationInfo { # Reads MigrationTable.json for the folder and reflects same-tenant state # on the form. Module function, not a captured scriptblock - handlers # cannot reach captures. param([string]$Folder) $st = $script:_bulkImportFormState if (-not $st) { return } if (-not $Folder -or -not [IO.Directory]::Exists($Folder)) { if ($st.LblMigInfo) { $st.LblMigInfo.Text = '' } return } try { $migrationInfo, $sameTenant = Get-MigrationTableInfo $Folder $script:OrganizationId $st.ImportSettings.SameTenant = [bool]$sameTenant if ($st.LblMigInfo) { $st.LblMigInfo.Text = [string]$migrationInfo } if ($st.ChkReplaceDeps) { $st.ChkReplaceDeps.IsEnabled = (-not $sameTenant) $st.ChkReplaceDeps.IsChecked = (-not $sameTenant) } } catch { Write-LogError "Failed to read migration table info" $_.Exception } }