# Avalonia bulk-documentation dialog. New file because Bulk Documentation is a # self-contained subsystem (R9 — keep IntuneManagerUIAvalonia from growing # further) and the WPF original at UI/WPF/Extensions/IntuneManagerUI.ps1 isn't # the only consumer; the same Start-GraphBulkDocumentation function it calls # also drives the silent batch path (Tests/Setup/Invoke-AutomationOrchestrator) # so the UI logic stays thin. function Update-BulkDocumentationObjectList { if (-not $script:dgBulkDocObjects) { return } $rows = [System.Collections.Generic.List[BulkDocumentationRowItem]]::new() if ($script:bulkDocMode -eq "Object") { foreach ($policy in @($script:bulkDocPolicyObjects | Sort-Object Name)) { $rows.Add([BulkDocumentationRowItem]@{ Title = [string]$policy.Name Selected = $true ObjectGroup = $null ObjectType = $policy.PolicyType PolicyObject = $policy }) } } elseif ($script:bulkDocMode -eq "Type") { $documentableGroupIds = @($script:bulkDocDocumentableGroups | ForEach-Object { $_.Id }) $sortedTypes = $script:IntuneTypes | Where-Object { $_.PolicyGroup -and ($documentableGroupIds -contains $_.PolicyGroup.Id) } | Sort-Object Title foreach ($intuneType in $sortedTypes) { $rows.Add([BulkDocumentationRowItem]@{ Title = [string]$intuneType.Title Selected = $true ObjectGroup = $null ObjectType = $intuneType }) } } else { $sortedGroups = $script:bulkDocDocumentableGroups | Sort-Object Title foreach ($intuneGroup in $sortedGroups) { $rows.Add([BulkDocumentationRowItem]@{ Title = [string]$intuneGroup.Title Selected = $true ObjectGroup = $intuneGroup ObjectType = $null }) } } $script:bulkDocRows = @($rows) $script:dgBulkDocObjects.ItemsSource = $script:bulkDocRows } function Get-BulkDocumentationSelectedIds { if ($null -eq $script:bulkDocRows) { return @() } if ($script:bulkDocMode -eq "Type") { return @($script:bulkDocRows | Where-Object { $_.Selected -and $_.ObjectType -and $_.ObjectType.Id } | ForEach-Object { $_.ObjectType.Id }) } return @($script:bulkDocRows | Where-Object { $_.Selected -and $_.ObjectGroup -and $_.ObjectGroup.Id } | ForEach-Object { $_.ObjectGroup.Id }) } function Get-BulkDocumentationSelectedObjects { if ($null -eq $script:bulkDocRows) { return @() } return @($script:bulkDocRows | Where-Object { $_.Selected -and $_.PolicyObject } | ForEach-Object { $_.PolicyObject }) } function ConvertTo-AvaloniaComboItem { # Wrap whatever the caller passes (string / PSCustomObject{Name,Value} / # PSCustomObject{EnglishName,Name} for languages) in a NameValueComboItem # so DisplayMemberBinding="{Binding Name|EnglishName}" finds a real CLR # property to render. SelectedItem.Value is preserved for the existing # readers; .Source carries the original object for callers that need # extra fields (Get-DocumentationLanguages returns CultureInfo). param($Item) if ($null -eq $Item) { return $null } if ($Item -is [NameValueComboItem]) { return $Item } $row = [NameValueComboItem]::new() $row.Source = $Item if ($Item -is [string]) { $row.Name = $Item; $row.EnglishName = $Item; $row.Value = $Item return $row } if ($Item.PSObject.Properties['EnglishName']) { $row.Name = [string]$Item.EnglishName $row.EnglishName = [string]$Item.EnglishName $row.Value = if ($Item.PSObject.Properties['Value']) { $Item.Value } elseif ($Item.PSObject.Properties['Name']) { $Item.Name } else { $Item.EnglishName } return $row } if ($Item.PSObject.Properties['Name']) { $row.Name = [string]$Item.Name $row.EnglishName = [string]$Item.Name $row.Value = if ($Item.PSObject.Properties['Value']) { $Item.Value } else { $Item.Name } return $row } $row.Name = [string]$Item; $row.EnglishName = [string]$Item; $row.Value = $Item return $row } function Set-AvaloniaDocumentationCombo { param($HostType, $Form, [string]$Name, $Items, $SelectedValue) $control = $HostType::FindByName($Form, $Name) if (-not $control) { return } $projected = @($Items | ForEach-Object { ConvertTo-AvaloniaComboItem $_ }) $control.ItemsSource = $projected # Display field comes from DisplayMemberBinding on the control in # BulkDocumentationForm.axaml - nothing to set here. $match = @($projected | Where-Object { $_.Value -eq $SelectedValue }) | Select-Object -First 1 $control.SelectedItem = if ($match) { $match } elseif ($projected.Count -gt 0) { $projected[0] } else { $null } } # Avalonia parity for Set-WpfDocumentationConditionalVisibility — show/hide # $TargetNames whenever $TriggerName's selected value equals $VisibleWhen. # State goes in $script:_docCondVisibility so the event handler can re-resolve # controls by name; closures captured via GetNewClosure() get scrubbed by # ConvertTo-AvaloniaEventScriptBlock (see feedback_avalonia_closure_dynamic_module). function Set-AvaloniaDocumentationConditionalVisibility { param( $HostType, $Form, [string]$TriggerName, [string]$VisibleWhen, [string[]]$TargetNames ) $trigger = $HostType::FindByName($Form, $TriggerName) if (-not $trigger) { return } $targets = @($TargetNames | ForEach-Object { $HostType::FindByName($Form, $_) } | Where-Object { $_ }) if ($targets.Count -eq 0) { return } if (-not $script:_docCondVisibility) { $script:_docCondVisibility = @{} } $script:_docCondVisibility[$TriggerName] = [PSCustomObject]@{ VisibleWhen = $VisibleWhen TargetNames = $TargetNames HostType = $HostType Form = $Form } Update-AvaloniaDocumentationConditionalVisibility -TriggerName $TriggerName $trigger.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock { # $this is the trigger control in Avalonia event handlers. Update-AvaloniaDocumentationConditionalVisibility -TriggerName $this.Name })) } function Update-AvaloniaDocumentationConditionalVisibility { param([string]$TriggerName) if (-not $script:_docCondVisibility -or -not $script:_docCondVisibility.ContainsKey($TriggerName)) { return } $entry = $script:_docCondVisibility[$TriggerName] $trigger = $entry.HostType::FindByName($entry.Form, $TriggerName) if (-not $trigger) { return } $item = $trigger.SelectedItem $current = if ($null -eq $item) { $null } elseif ($item.PSObject.Properties['Value']) { $item.Value } elseif ($item.PSObject.Properties['Name']) { $item.Name } else { $item } $visible = [string]$current -eq $entry.VisibleWhen foreach ($name in $entry.TargetNames) { $t = $entry.HostType::FindByName($entry.Form, $name) if ($t) { $t.IsVisible = $visible } } } function Initialize-AvaloniaDocumentationOptions { param($HostType, $Form) Set-AvaloniaDocumentationCombo $HostType $Form 'cbDocumentationLanguage' (Get-DocumentationLanguages | Sort-Object EnglishName) (Get-DocumentationSetting 'Language' 'en') Set-AvaloniaDocumentationCombo $HostType $Form 'cbDocumentationPropertySeparator' @(',',';','-','|') (Get-DocumentationSetting 'PropertySeparator' ';') Set-AvaloniaDocumentationCombo $HostType $Form 'cbDocumentationObjectSeparator' @([PSCustomObject]@{Name='New line';Value=[Environment]::NewLine},[PSCustomObject]@{Name=';';Value=';'},[PSCustomObject]@{Name='|';Value='|'}) (Get-DocumentationSetting 'ObjectSeparator' ([Environment]::NewLine)) Set-AvaloniaDocumentationCombo $HostType $Form 'cbNotConfiguredText' @([PSCustomObject]@{Name='Not configured (localized)';Value='notConfigured'},[PSCustomObject]@{Name='Empty';Value='empty'},[PSCustomObject]@{Name="Don't change";Value='asis'}) (Get-DocumentationSetting 'NotConfiguredText' 'notConfigured') Set-AvaloniaDocumentationCombo $HostType $Form 'cbValueOutputProperty' @([PSCustomObject]@{Name='Value';Value='value'},[PSCustomObject]@{Name='Value with label';Value='valueWithLabel'}) (Get-DocumentationSetting 'ValueOutputProperty' 'value') $propertyModes = @([PSCustomObject]@{Name='Simple';Value='simple'},[PSCustomObject]@{Name='Extended';Value='extended'},[PSCustomObject]@{Name='Custom';Value='custom'}) $fileModes = @([PSCustomObject]@{Name='Single file';Value='Full'},[PSCustomObject]@{Name='One file per object';Value='Object'}) Set-AvaloniaDocumentationCombo $HostType $Form 'cbCSVDocumentationProperties' $propertyModes (Get-DocumentationSetting 'CSVExportProperties' 'simple') Set-AvaloniaDocumentationCombo $HostType $Form 'cbCSVDelimiter' @('',',',';','-','|') (Get-DocumentationSetting 'CSVDelimiter' '') Set-AvaloniaDocumentationCombo $HostType $Form 'cbJsonOutputFileType' @([PSCustomObject]@{Name='Single file';Value='Full'},[PSCustomObject]@{Name='One file per object type';Value='ObjectType'}) (Get-DocumentationSetting 'JSONOutputFileType' 'Full') Set-AvaloniaDocumentationCombo $HostType $Form 'cbHTMLDocumentFileType' $fileModes (Get-DocumentationSetting 'HTMLDocumentFileType' 'Full') Set-AvaloniaDocumentationCombo $HostType $Form 'cbAtlassianDocumentFileType' $fileModes (Get-DocumentationSetting 'AtlassianDocumentFileType' 'Full') Set-AvaloniaDocumentationCombo $HostType $Form 'cbMDDocumentFileType' $fileModes (Get-DocumentationSetting 'MDDocumentFileType' 'Full') Set-AvaloniaDocumentationCombo $HostType $Form 'cbWordExportProperties' $propertyModes (Get-DocumentationSetting 'WordExportProperties' 'simple') Set-AvaloniaDocumentationCombo $HostType $Form 'cbWordDocumentFormat' @([PSCustomObject]@{Name='DOCX';Value='wdFormatDocumentDefault'},[PSCustomObject]@{Name='Strict Open XML';Value='wdFormatStrictOpenXMLDocument'},[PSCustomObject]@{Name='PDF';Value='wdFormatPDF'}) (Get-DocumentationSetting 'WordDocumentFormat' 'wdFormatDocumentDefault') Set-AvaloniaDocumentationCombo $HostType $Form 'cbWordDocumentationLevel' @([PSCustomObject]@{Name='Full';Value='full'},[PSCustomObject]@{Name='Limited';Value='limited'},[PSCustomObject]@{Name='Basic';Value='basic'}) (Get-DocumentationSetting 'WordDocumentationLevel' 'full') Set-AvaloniaDocumentationCombo $HostType $Form 'cbWordTableCaptionPosition' @([PSCustomObject]@{Name='Below table';Value='below'},[PSCustomObject]@{Name='Above table';Value='above'}) (Get-DocumentationSetting 'WordTableCaptionPosition' 'below') $text = @{txtCSVDocumentationPath='CSVDocumentationPath';txtCSVCustomProperties='CSVCustomDisplayProperties';txtJsonDocumentName='JSONDocumentName';txtHTMLDocumentName='HTMLDocumentName';txtHTMLCSSFile='HTMLCSSFile';txtHTMLTitleProperty='HTMLTitleProperty';txtAtlassianDocumentName='AtlassianDocumentName';txtAtlassianTitleProperty='AtlassianTitleProperty';txtMDDocumentName='MDDocumentName';txtMDCSSFile='MDCSSFile';txtMDTitleProperty='MDTitleProperty';txtWordDocumentName='WordDocumentName';txtWordDocumentTemplate='WordDocumentTemplate';txtWordCustomDisplayProperties='WordCustomDisplayProperties';txtWordDocumentationLimitMaxLength='WordDocumentationLimitMaxLength';txtWordDocumentationLimitTruncateLength='WordDocumentationLimitTruncateLength';txtWordTitleProperty='WordTitleProperty';txtWordSubjectProperty='WordSubjectProperty';txtWordContentControls='WordContentControls';txtWordCoverPage='WordCoverPage';txtWordHeader1Style='WordHeader1Style';txtWordHeader2Style='WordHeader2Style';txtWordHeader3Style='WordHeader3Style';txtWordTableStyle='WordTableStyle';txtWordTableHeaderStyle='WordTableHeaderStyle';txtWordCategoryHeaderStyle='WordCategoryHeaderStyle';txtWordSubCategoryHeaderStyle='WordSubCategoryHeaderStyle';txtWordTableTextStyle='WordTableTextStyle';txtWordScriptTableStyle='WordScriptTableStyle';txtWordScriptStyle='WordScriptStyle'} foreach($name in $text.Keys) { $c = $HostType::FindByName($Form, $name) # Skip when an AXAML file doesn't carry the field yet — assigning to a # null control would throw NullReferenceException and abort the whole # form load. if ($c) { $c.Text = [string](Get-DocumentationSetting $text[$name] '') } } foreach($pair in @(@('chkSkipNotConfigured','SkipNotConfigured',$false),@('chkSkipDefaultValues','SkipDefaultValues',$false),@('chkSkipDisabled','SkipDisabled',$true),@('chkSetUnconfiguredValue','SetUnconfiguredValue',$true),@('chkSetDefaultValue','SetDefaultValue',$false),@('chkIncludeScripts','IncludeScripts',$true),@('chkExcludeScriptSignature','ExcludeScriptSignature',$false),@('chkExcludeAssignments','ExcludeAssignments',$false),@('chkIncludePolicyId','IncludePolicyId',$false),@('chkSkipDocumentInfo','SkipDocumentInfo',$false),@('chkFallbackDocumentation','FallbackDocumentation',$true),@('chkCSVAddObjectType','CSVAddObjectType',$true),@('chkCSVAddCompanyName','CSVAddCompanyName',$false),@('chkJsonOpenFile','JSONOpenFile',$true),@('chkHTMLOpenFile','HTMLOpenFile',$true),@('chkAtlassianOpenFile','AtlassianOpenFile',$true),@('chkMDIncludeCSS','MDIncludeCSS',$true),@('chkMDOpenFile','MDOpenFile',$true),@('chkMDDocumentSkipDate','MDDocumentSkipDate',$false),@('chkWordDocumentationLimitAttach','WordDocumentationLimitAttach',$false),@('chkWordAddCategories','WordAddCategories',$true),@('chkWordAddSubCategories','WordAddSubCategories',$true),@('chkWordOpenDocument','WordOpenDocument',$true),@('chkWordAttachJsonFile','WordAttachJsonFile',$false))) { $c = $HostType::FindByName($Form, $pair[0]) if ($c) { $c.IsChecked = (Get-DocumentationSetting $pair[1] $pair[2]) -eq $true } } # Conditional-visibility wiring (Word custom-properties row, Word # limit-options grid, CSV custom-properties row). Mirrors the WPF # Set-WpfDocumentationConditionalVisibility calls. Set-AvaloniaDocumentationConditionalVisibility -HostType $HostType -Form $Form -TriggerName 'cbWordExportProperties' -VisibleWhen 'custom' ` -TargetNames @('spWordCustomProperties','txtWordCustomDisplayProperties') Set-AvaloniaDocumentationConditionalVisibility -HostType $HostType -Form $Form -TriggerName 'cbWordDocumentationLevel' -VisibleWhen 'limited' ` -TargetNames @('gdWordDocumentationLimitOptions') Set-AvaloniaDocumentationConditionalVisibility -HostType $HostType -Form $Form -TriggerName 'cbCSVDocumentationProperties' -VisibleWhen 'custom' ` -TargetNames @('spCSVCustomProperties','txtCSVCustomProperties') } function Get-AvaloniaDocumentationOptions { param($HostType, $Form) function C($n) { $HostType::FindByName($Form,$n) } # Return $null when the control is missing so Save-DocumentationOptionsDefaults # skips writing — avoids erasing a real persisted value with an empty string # from a non-existent XAML field. function Txt($n) { $c = C $n; if ($c) { [string]$c.Text } else { $null } } function Sel($n) { $c = C $n; if (-not $c) { return $null }; $v=$c.SelectedItem; if($v -and $v.PSObject.Properties['Value']){$v.Value}elseif($v -and $v.PSObject.Properties['Name']){$v.Name}else{$v} } function Chk($n) { $c = C $n; if ($c) { [bool]$c.IsChecked } else { $null } } @{Language=Sel 'cbDocumentationLanguage';PropertySeparator=Sel 'cbDocumentationPropertySeparator';ObjectSeparator=Sel 'cbDocumentationObjectSeparator';NameFilter=Txt 'txtDocumentFilter';SourceFolder=Txt 'txtDocumentFromFolder';SkipNotConfigured=Chk 'chkSkipNotConfigured';SkipDefaultValues=Chk 'chkSkipDefaultValues';SkipDisabled=Chk 'chkSkipDisabled';SetUnconfiguredValue=Chk 'chkSetUnconfiguredValue';SetDefaultValue=Chk 'chkSetDefaultValue';IncludeScripts=Chk 'chkIncludeScripts';ExcludeScriptSignature=Chk 'chkExcludeScriptSignature';ExcludeAssignments=Chk 'chkExcludeAssignments';IncludePolicyId=Chk 'chkIncludePolicyId';SkipDocumentInfo=Chk 'chkSkipDocumentInfo'; FallbackDocumentation=Chk 'chkFallbackDocumentation';NotConfiguredText=Sel 'cbNotConfiguredText';ValueOutputProperty=Sel 'cbValueOutputProperty';Outputs=@{csv=@{CSVDocumentationPath=Txt 'txtCSVDocumentationPath';CSVExportProperties=Sel 'cbCSVDocumentationProperties';CSVCustomDisplayProperties=Txt 'txtCSVCustomProperties';CSVDelimiter=Sel 'cbCSVDelimiter';CSVAddObjectType=Chk 'chkCSVAddObjectType';CSVAddCompanyName=Chk 'chkCSVAddCompanyName'};json=@{JSONDocumentName=Txt 'txtJsonDocumentName';JSONOpenFile=Chk 'chkJsonOpenFile';JSONOutputFileType=Sel 'cbJsonOutputFileType'};html=@{HTMLDocumentName=Txt 'txtHTMLDocumentName';HTMLCSSFile=Txt 'txtHTMLCSSFile';HTMLTitleProperty=Txt 'txtHTMLTitleProperty';HTMLOpenFile=Chk 'chkHTMLOpenFile';HTMLDocumentFileType=Sel 'cbHTMLDocumentFileType'};atlassian=@{AtlassianDocumentName=Txt 'txtAtlassianDocumentName';AtlassianTitleProperty=Txt 'txtAtlassianTitleProperty';AtlassianOpenFile=Chk 'chkAtlassianOpenFile';AtlassianDocumentFileType=Sel 'cbAtlassianDocumentFileType'};md=@{MDDocumentName=Txt 'txtMDDocumentName';MDCSSFile=Txt 'txtMDCSSFile';MDTitleProperty=Txt 'txtMDTitleProperty';MDIncludeCSS=Chk 'chkMDIncludeCSS';MDOpenFile=Chk 'chkMDOpenFile';MDDocumentSkipDate=Chk 'chkMDDocumentSkipDate';MDDocumentFileType=Sel 'cbMDDocumentFileType'};word=@{WordDocumentName=Txt 'txtWordDocumentName';WordDocumentTemplate=Txt 'txtWordDocumentTemplate';WordExportProperties=Sel 'cbWordExportProperties';WordCustomDisplayProperties=Txt 'txtWordCustomDisplayProperties';WordDocumentFormat=Sel 'cbWordDocumentFormat';WordDocumentationLevel=Sel 'cbWordDocumentationLevel';WordDocumentationLimitMaxLength=Txt 'txtWordDocumentationLimitMaxLength';WordDocumentationLimitTruncateLength=Txt 'txtWordDocumentationLimitTruncateLength';WordDocumentationLimitAttach=Chk 'chkWordDocumentationLimitAttach';WordAddCategories=Chk 'chkWordAddCategories';WordAddSubCategories=Chk 'chkWordAddSubCategories';WordOpenDocument=Chk 'chkWordOpenDocument';WordAttachJsonFile=Chk 'chkWordAttachJsonFile';WordTitleProperty=Txt 'txtWordTitleProperty';WordSubjectProperty=Txt 'txtWordSubjectProperty';WordContentControls=Txt 'txtWordContentControls';WordCoverPage=Txt 'txtWordCoverPage';WordHeader1Style=Txt 'txtWordHeader1Style';WordHeader2Style=Txt 'txtWordHeader2Style';WordHeader3Style=Txt 'txtWordHeader3Style';WordTableStyle=Txt 'txtWordTableStyle';WordTableHeaderStyle=Txt 'txtWordTableHeaderStyle';WordCategoryHeaderStyle=Txt 'txtWordCategoryHeaderStyle';WordSubCategoryHeaderStyle=Txt 'txtWordSubCategoryHeaderStyle';WordTableTextStyle=Txt 'txtWordTableTextStyle';WordTableCaptionPosition=Sel 'cbWordTableCaptionPosition';WordScriptTableStyle=Txt 'txtWordScriptTableStyle';WordScriptStyle=Txt 'txtWordScriptStyle'}}} } function Add-AvaloniaDocumentationFileBrowse { param($HostType, $Form, [string]$ButtonName, [string]$TextName, [switch]$Save, [string]$Title, [string]$Extension, [string]$FilterName, [string]$FilterPattern) $button = $HostType::FindByName($Form, $ButtonName) $text = $HostType::FindByName($Form, $TextName) if(-not $button -or -not $text) { return } # Per-button context rides on the sender's Tag: this helper is called once # per registry-declared picker (7+ times), so a single module-scope slot # would be overwritten by each successive call - and the captured locals it # used before were stripped, leaving every provider's browse button dead. $button.Tag = [PSCustomObject]@{ TextBox = $text; Save = [bool]$Save; Title = $Title Extension = $Extension; FilterName = $FilterName; FilterPattern = $FilterPattern } $button.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) $cfg = $S.Tag if(-not $cfg -or -not $cfg.TextBox) { return } $hostT = Get-AvaloniaHost $current = [string]$cfg.TextBox.Text if($cfg.Save) { $suggested = if($current) { [IO.Path]::GetFileName($current) } else { '' } $picked = $hostT::SaveFilePicker($script:Window, $cfg.Title, $suggested, $cfg.Extension, $cfg.FilterName, $cfg.FilterPattern) } else { $start = if($current) { [IO.Path]::GetDirectoryName($current) } else { '' } $picked = $hostT::OpenFilePicker($script:Window, $cfg.Title, $start, $cfg.FilterName, $cfg.FilterPattern) } if($picked) { $cfg.TextBox.Text = $picked } })) } function Set-AvaloniaDocumentationOutputPanel { param($HostType, $Form, [string]$OutputValue) foreach($output in [DocumentationRegistry]::Outputs) { $panel = $HostType::FindByName($Form, "pnlDocumentationOutput$($output.Name)") if($panel) { $panel.IsVisible = ($output.Value -eq $OutputValue) } } } function Show-GraphBulkDocumentationForm { param([object[]]$PolicyObject) # $script: scope-prefixed references and bare module-private function # CORRECTION (2026-08-25): this note previously claimed the opposite. # $script: variables and bare module-function lookups DO resolve inside # Avalonia handlers; it is function-LOCAL captures that get stripped by # ConvertTo-AvaloniaEventScriptBlock. Handler state therefore lives in # $script:_bulkDocFormState, never in captured locals. $ui = $script:UIProvider $form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML\BulkDocumentationForm.axaml')) if (-not $form) { return } $hostType = Get-AvaloniaHost $script:dgBulkDocObjects = $hostType::FindByName($form, 'dgObjectsToDocument') $cbType = $hostType::FindByName($form, 'cbDocumentationType') $txtFolder = $hostType::FindByName($form, 'txtDocumentationOutputFolder') $btnBrowse = $hostType::FindByName($form, 'browseDocumentationOutputFolder') $txtSource = $hostType::FindByName($form, 'txtDocumentFromFolder') $btnSource = $hostType::FindByName($form, 'browseDocumentFromFolder') $txtLang = $hostType::FindByName($form, 'cbDocumentationLanguage') $chkIncScr = $hostType::FindByName($form, 'chkIncludeScripts') $chkExcSig = $hostType::FindByName($form, 'chkExcludeScriptSignature') $chkExcAssign = $hostType::FindByName($form, 'chkExcludeAssignments') $chkIncPolId = $hostType::FindByName($form, 'chkIncludePolicyId') $rbGroup = $hostType::FindByName($form, 'rbBulkDocViewGroup') $rbType = $hostType::FindByName($form, 'rbBulkDocViewType') $pnlListBy = $hostType::FindByName($form, 'pnlDocumentationListBy') # The label rows are Label + info-icon inside a horizontal StackPanel, so the # wrapper is what has to be hidden - toggling the inner Label alone leaves the # panel occupying its row with the info icon still showing. $pnlFilterLbl = $hostType::FindByName($form, 'pnlDocumentFilterLabel') $txtFilter = $hostType::FindByName($form, 'txtDocumentFilter') $pnlSourceLbl = $hostType::FindByName($form, 'pnlDocumentFromFolderLabel') $pnlSource = $hostType::FindByName($form, 'pnlDocumentFromFolder') $btnDoc = $hostType::FindByName($form, 'btnDocument') $btnClose = $hostType::FindByName($form, 'btnClose') $btnPreview = $hostType::FindByName($form, 'btnPreviewDocumentation') $btnCopyRaw = $hostType::FindByName($form, 'btnCopyRawDocumentation') $btnCopyBasic = $hostType::FindByName($form, 'btnCopyBasicDocumentation') $btnCopySettings = $hostType::FindByName($form, 'btnCopySettingsDocumentation') $txtRaw = $hostType::FindByName($form, 'txtDocumentationRawData') $script:_bulkDocFormState = [ordered]@{ HostType = $hostType; Form = $form CbType = $cbType; TxtFolder = $txtFolder; TxtSource = $txtSource TxtLang = $txtLang; ChkIncScr = $chkIncScr; ChkExcSig = $chkExcSig ChkExcAssign = $chkExcAssign; ChkIncPolId = $chkIncPolId TxtFilter = $txtFilter; TxtRaw = $txtRaw; HeaderCb = $null } $script:bulkDocPreview = @() # ---- Populate output-format combo from the registry ---- if ($cbType) { # Project to NameValueComboItem so Avalonia's DisplayMemberBinding can # read .Name off a real CLR property (PSCustomObject NoteProperties # don't bind — see [[avalonia-binding-needs-clr-types]]). $outputs = @([DocumentationRegistry]::Outputs | Sort-Object Name | ForEach-Object { ConvertTo-AvaloniaComboItem ([PSCustomObject]@{ Name = $_.Name; Value = $_.Value }) }) $cbType.ItemsSource = $outputs if ($outputs.Count -gt 0) { $last = Get-SettingStoreValue "Documentation" "OutputType" "json" $match = $outputs | Where-Object Value -EQ $last | Select-Object -First 1 $cbType.SelectedItem = if ($match) { $match } else { $outputs[0] } } $selectedOutput = if($cbType.SelectedItem) { [string]$cbType.SelectedItem.Value } else { '' } Set-AvaloniaDocumentationOutputPanel $hostType $form $selectedOutput $cbType.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_bulkDocFormState if (-not $st) { return } $value = if($st.CbType -and $st.CbType.SelectedItem) { [string]$st.CbType.SelectedItem.Value } else { '' } Set-AvaloniaDocumentationOutputPanel $st.HostType $st.Form $value })) } # ---- Restore persisted form values ---- if ($txtFolder) { $txtFolder.Text = [string](Get-SettingStoreValue "Documentation" "OutputFolder" "") } if ($txtSource) { $txtSource.Text = [string](Get-DocumentationSetting 'SourceFolder' '') } if ($txtFilter) { $txtFilter.Text = [string](Get-DocumentationSetting 'NameFilter' '') } Initialize-AvaloniaDocumentationOptions $hostType $form # ---- Populate object list (same Groups filter as Bulk Export) ---- $script:bulkDocDocumentableGroups = @($script:IntuneGroups | Where-Object { $_.Title -and (-not ($_.ShowButtons -is [Object[]]) -or $_.ShowButtons -contains "Document") }) $script:bulkDocPolicyObjects = @($PolicyObject | Where-Object { $null -ne $_ }) $script:bulkDocMode = if($script:bulkDocPolicyObjects.Count -gt 0) { "Object" } else { "Group" } if($pnlListBy) { $pnlListBy.IsVisible = ($script:bulkDocMode -ne "Object") } foreach($bulkOnlyControl in @($pnlFilterLbl,$txtFilter,$pnlSourceLbl,$pnlSource)) { if($bulkOnlyControl) { $bulkOnlyControl.IsVisible = ($script:bulkDocMode -ne "Object") } } Update-BulkDocumentationObjectList if ($btnBrowse) { $btnBrowse.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_bulkDocFormState if (-not $st) { return } $folder = $script:UIProvider.ShowFolderPicker(([string]$st.TxtFolder.Text), 'Select root folder for documentation') if ($folder -and $st.TxtFolder) { $st.TxtFolder.Text = $folder } })) } if ($btnSource) { $btnSource.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_bulkDocFormState if (-not $st) { return } $folder = $script:UIProvider.ShowFolderPicker(([string]$st.TxtSource.Text), 'Select exported source folder') if ($folder -and $st.TxtSource) { $st.TxtSource.Text = $folder } })) } $btnCSVFolder = $hostType::FindByName($form, 'browseCSVDocumentationPath') if ($btnCSVFolder) { $btnCSVFolder.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_bulkDocFormState if (-not $st) { return } $tb = $st.HostType::FindByName($st.Form, 'txtCSVDocumentationPath') $current = if ($tb) { [string]$tb.Text } else { '' } $folder = $script:UIProvider.ShowFolderPicker($current, 'Select CSV documentation folder') if ($folder -and $tb) { $tb.Text = $folder } })) } # Registry-driven file-browse wiring: each output provider optionally declares # a FileBrowseControls array in its Add-DocumentationOutputProvider hashtable # (see e.g. DocumentationOutputHTML.ps1). Any browse button in the XAML whose # name matches an entry gets wired here — adding a new provider does not # require editing this file. Buttons whose XAML controls are absent (e.g. a # provider that declares controls the current XAML doesn't include) are # ignored by Add-AvaloniaDocumentationFileBrowse's null-check. foreach($output in [DocumentationRegistry]::Outputs) { if(-not $output.PSObject.Properties['FileBrowseControls']) { continue } foreach($fb in @($output.FileBrowseControls)) { $params = @{ HostType = $hostType Form = $form ButtonName = $fb.Button TextName = $fb.TextBox Title = $fb.Title FilterName = $fb.FilterName FilterPattern = $fb.FilterPattern } if($fb.Save) { $params['Save'] = $true } if($fb.Extension) { $params['Extension'] = $fb.Extension } Add-AvaloniaDocumentationFileBrowse @params } } # Select/deselect-all moved into the DataGrid column header. $headerCb = Initialize-AvaloniaGridSelectAllHeader -Grid $script:dgBulkDocObjects -BindingProperty 'Selected' -InitiallyChecked $true $script:_bulkDocFormState.HeaderCb = $headerCb if ($rbGroup) { $rbGroup.add_IsCheckedChanged((ConvertTo-AvaloniaEventScriptBlock { param($s, $e) if (-not $s.IsChecked) { return } $script:bulkDocMode = "Group" Update-BulkDocumentationObjectList $st = $script:_bulkDocFormState if ($st -and $st.HeaderCb) { $st.HeaderCb.IsChecked = $true } })) } if ($rbType) { $rbType.add_IsCheckedChanged((ConvertTo-AvaloniaEventScriptBlock { param($s, $e) if (-not $s.IsChecked) { return } $script:bulkDocMode = "Type" Update-BulkDocumentationObjectList $st = $script:_bulkDocFormState if ($st -and $st.HeaderCb) { $st.HeaderCb.IsChecked = $true } })) } if ($btnDoc) { $btnDoc.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_bulkDocFormState if (-not $st) { return } $selection = if($script:bulkDocMode -eq "Object") { Get-BulkDocumentationSelectedObjects } else { Get-BulkDocumentationSelectedIds } $unit = if ($script:bulkDocMode -eq "Object") { "policy" } elseif ($script:bulkDocMode -eq "Type") { "policy type" } else { "object group" } if ($selection.Count -eq 0) { $ui.ShowMessageBox("Select at least one $unit to document.", "Bulk Documentation", "OK", "Warning") | Out-Null return } # Read the selected output format $outputValue = $null if ($st.CbType -and $st.CbType.SelectedItem) { $outputValue = [string]$st.CbType.SelectedItem.Value } if (-not $outputValue) { $ui.ShowMessageBox("Select an output format.", "Bulk Documentation", "OK", "Warning") | Out-Null return } $folder = if ($st.TxtFolder) { [string]$st.TxtFolder.Text } else { $null } if ($folder) { $folder = $folder.Trim().Trim('"').Trim("'") } $options = Get-AvaloniaDocumentationOptions $st.HostType $st.Form if (-not $folder) { # Registry-driven fallback: ask the provider where it stores # its output path and whether that path is a folder. Same # pattern as the WPF twin — adding a provider needs no change # here, just PrimaryPathOption/PathIsFolder on registration. $provider = [DocumentationRegistry]::FindOutput($outputValue) $selectedPath = if($provider -and $provider.PSObject.Properties['PrimaryPathOption'] -and $provider.PrimaryPathOption) { $providerOpts = $options.Outputs[$outputValue] if($providerOpts) { $providerOpts[$provider.PrimaryPathOption] } } if($selectedPath) { $folder = if($provider -and $provider.PSObject.Properties['PathIsFolder'] -and $provider.PathIsFolder) { $selectedPath } else { Split-Path -Parent $selectedPath } } if(-not $folder) { $folder = [Environment]::GetFolderPath('MyDocuments') } } try { $folder = [IO.Path]::GetFullPath($folder) } catch { $ui.ShowMessageBox("Invalid output folder path: $($_.Exception.Message)", "Bulk Documentation", "OK", "Error") | Out-Null return } $language = [string]$options.Language if (-not $language) { $language = 'en' } $defaultName = "%Organization%-%Date%" if(-not $options.Outputs.csv.CSVDocumentationPath) { $options.Outputs.csv.CSVDocumentationPath = $folder } if(-not $options.Outputs.json.JSONDocumentName) { $options.Outputs.json.JSONDocumentName = Join-Path $folder "$defaultName.json" } if(-not $options.Outputs.html.HTMLDocumentName) { $options.Outputs.html.HTMLDocumentName = Join-Path $folder "$defaultName.html" } # Confluence storage format is XHTML, so .html keeps it openable in a browser. if(-not $options.Outputs.atlassian.AtlassianDocumentName) { $options.Outputs.atlassian.AtlassianDocumentName = Join-Path $folder "$defaultName.html" } if(-not $options.Outputs.md.MDDocumentName) { $options.Outputs.md.MDDocumentName = Join-Path $folder "$defaultName.md" } if(-not $options.Outputs.word.WordDocumentName) { $options.Outputs.word.WordDocumentName = Join-Path $folder "$defaultName.docx" } # Persist UI defaults for next time. The public call below receives # the complete options object and never depends on these settings. Save-SettingStoreValue "Documentation" "OutputType" $outputValue Save-SettingStoreValue "Documentation" "OutputFolder" $folder Save-DocumentationOptionsDefaults $options $startParams = @{ OutputFormat = $outputValue Language = $language Options = $options } if ($script:bulkDocMode -ne "Object" -and $options.SourceFolder) { $startParams.SourceFolder = $options.SourceFolder } if ($script:bulkDocMode -eq "Type") { $startParams.PolicyType = $selection } elseif ($script:bulkDocMode -eq "Group") { $startParams.PolicyGroup = $selection } Write-Log "Documentation starting. Mode=$script:bulkDocMode Format=$outputValue Folder=$folder Selection=$($selection -join ',')" try { if($script:bulkDocMode -eq "Object") { [void]($selection | Start-GraphBulkDocumentation @startParams) } else { [void](Start-GraphBulkDocumentation @startParams) } Write-Status "" $ui.ShowMessageBox(("Documentation finished. Output folder:`n{0}" -f $folder), "Bulk Documentation", "OK", "Information") | Out-Null } catch { Write-LogError "Bulk documentation failed" $_.Exception Write-Status "" $ui.ShowMessageBox("Bulk documentation failed: $($_.Exception.Message)", "Bulk Documentation", "OK", "Error") | Out-Null } })) } if ($btnPreview) { $btnPreview.add_Click((ConvertTo-AvaloniaEventScriptBlock { $selection = if($script:bulkDocMode -eq "Object") { Get-BulkDocumentationSelectedObjects } else { Get-BulkDocumentationSelectedIds } if ($selection.Count -eq 0) { return } $st = $script:_bulkDocFormState if (-not $st) { return } $options = Get-AvaloniaDocumentationOptions $st.HostType $st.Form $params = if ($script:bulkDocMode -eq 'Type') { @{PolicyType=$selection} } elseif($script:bulkDocMode -eq 'Group') { @{PolicyGroup=$selection} } else { @{} } if($script:bulkDocMode -eq 'Object') { $policies = $selection } elseif ($options.SourceFolder) { $options.SourceTenantUnavailable = $true Initialize-DocumentationSourceTenantContext -SourceFolder $options.SourceFolder $policies = Get-DocumentationPoliciesFromSourceFolder -SourceFolder $options.SourceFolder @params } else { $policies = Get-GraphPolicies @params } $script:bulkDocPreview = @($policies | Select-Object -First 5 | Get-GraphDocumentation -Language $options.Language -Options $options) if ($st.TxtRaw) { $st.TxtRaw.Text = ($script:bulkDocPreview | ConvertTo-Json -Depth 10) } })) } if ($btnCopyBasic) { $btnCopyBasic.add_Click((ConvertTo-AvaloniaEventScriptBlock { @($script:bulkDocPreview | ForEach-Object BasicInfo) | Select-Object Name,Value | ConvertTo-Csv -NoTypeInformation | Set-Clipboard })) } if ($btnCopySettings) { $btnCopySettings.add_Click((ConvertTo-AvaloniaEventScriptBlock { @($script:bulkDocPreview | ForEach-Object FilteredSettings) | ConvertTo-Csv -NoTypeInformation | Set-Clipboard })) } if ($btnCopyRaw) { $btnCopyRaw.add_Click((ConvertTo-AvaloniaEventScriptBlock { $st = $script:_bulkDocFormState if ($st -and $st.TxtRaw) { [string]$st.TxtRaw.Text | Set-Clipboard } })) } if ($btnClose) { $btnClose.add_Click((ConvertTo-AvaloniaEventScriptBlock { $script:dgBulkDocObjects = $null $script:bulkDocRows = $null $script:bulkDocPolicyObjects = $null $script:_bulkDocFormState = $null Close-TopModalObject })) } $form.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock { param($s, $e) if ($e.Key -eq [Avalonia.Input.Key]::Escape) { $script:dgBulkDocObjects = $null $script:bulkDocRows = $null $script:bulkDocPolicyObjects = $null $script:_bulkDocFormState = $null Close-TopModalObject $e.Handled = $true } })) $title = if($script:bulkDocMode -eq 'Object') { 'Document' } else { 'Bulk Documentation' } $ui.ShowModalForm($title, $form, $true) } # Compatibility wrapper for the main-view Document button. The shared form # switches to Object mode and displays the selected policies. function Show-IntuneManagerDocumentForm { param([object[]]$PolicyObject) Show-GraphBulkDocumentationForm -PolicyObject $PolicyObject }