IntuneManagement 4.0.0-beta1

This commit is contained in:
Mikael Karlsson
2026-09-23 19:13:09 +10:00
commit 7869619510
892 changed files with 577109 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
# Documentation-only grouping for the tenant-default enrollment policies.
#
# deviceManagement/deviceEnrollmentConfigurations carries several policies that read
# as one enrollment-restrictions area, but each is its own policy TYPE here
# (EnrollmentLimit, EnrollmentRestrictions, EnrollmentStatusPage,
# WindowsHelloForBusiness, WindowsRestore). The document builds its second level from
# PolicyType.Title, so every one of those types got a heading of its own holding a
# single child - and because all five ship a tenant default named "All users and all
# devices", three of those children were indistinguishable from each other while the
# remaining two repeated their own heading word for word:
#
# Windows Hello for Business <- heading, from PolicyType.Title
# Windows Hello for Business <- the only child, same text
#
# Group the five under one heading, and title each child by its type's PolicyName
# ("Device limit restrictions", "Device platform restrictions", ...) so each entry
# says which policy it is.
#
# Deliberately documentation-only. The heading cannot come from _APITitle: that same
# property titles the app's navigation menu, so changing it there would rename the
# nav and the type's identity for every other consumer.
$script:_docEnrollmentGroup = [PSCustomObject]@{
# Grouping key, not a real policy-type id. The engine groups the second level on
# this value, so it must not collide with any PolicyType.Id or an unrelated type
# would be merged into this heading.
Id = 'DocEnrollmentRestrictions'
Title = 'Enrollment Restrictions'
TypeIds = @(
'EnrollmentLimit'
'EnrollmentRestrictions'
'EnrollmentStatusPage'
'WindowsHelloForBusiness'
'WindowsRestore'
)
}
# The documentation grouping a policy belongs to, or $null for everything else -
# which is every other policy type, so the engine keeps its normal per-type heading.
function Get-DocumentationTypeGroup {
param($PolicyObject)
$typeId = $null
if ($PolicyObject -and $PolicyObject.PolicyType) { $typeId = [string]$PolicyObject.PolicyType.Id }
if ($typeId -and $script:_docEnrollmentGroup.TypeIds -contains $typeId) {
return $script:_docEnrollmentGroup
}
return $null
}
# The title a grouped tenant-default policy is documented under, or $null to keep the
# policy's own display name.
#
# Only the tenant default is retitled. A custom policy of the same type - a second
# enrollment status page, a per-platform "Block Android Device Administrator
# Enrollment" restriction - has a real name of its own and must keep it, otherwise
# several of them would collapse onto the same title.
#
# Default-ness is read from two signals for the same reason
# DeviceEnrollmentObject.GetFileName (Classes/IntuneEnrollmentClasses.ps1) uses two:
# priority is not guaranteed to be present on every payload, and the id form is only
# reliable once the id has been populated. A policy that is default in either sense
# is treated as the default.
function Get-DocumentationEnrollmentDefaultName {
param($PolicyObject)
if (-not (Get-DocumentationTypeGroup $PolicyObject)) { return $null }
$obj = if ($PolicyObject.PSObject.Properties['JsonObject'] -and $PolicyObject.JsonObject) {
$PolicyObject.JsonObject
} else {
$PolicyObject
}
$isDefault = ($obj.priority -eq 0) -or ([string]$PolicyObject.Id -match '_Default')
if (-not $isDefault) { return $null }
# PolicyName first - "Device limit restrictions" says more than the heading-shaped
# "Enrollment Limit" - then Title, for a type that declares no _PolicyName.
$policyName = [string]$PolicyObject.PolicyType.PolicyName
if ([string]::IsNullOrWhiteSpace($policyName)) {
$policyName = [string]$PolicyObject.PolicyType.Title
}
if ([string]::IsNullOrWhiteSpace($policyName)) { return $null }
return $policyName
}
# The text an object will be titled with, resolvable BEFORE documentation runs.
#
# The engine sorts policies up front, but the retitling above happens per object
# while it is being documented, so sorting on .Name alone ordered the document by
# text the reader never sees - three "All users and all devices" siblings in
# arbitrary order. Sorting on this keeps the document and its table of contents in
# the same, stable order.
function Get-DocumentationSortName {
param($PolicyObject)
$name = Get-DocumentationEnrollmentDefaultName $PolicyObject
if ($name) { return $name }
return [string]$PolicyObject.Name
}
@@ -0,0 +1,191 @@
# Generic fallback documenter.
#
# Documents policy objects that match NO handler and NO input provider (the
# engine's 'NoProvider' path) instead of producing an empty stub. Output is a
# deliberately simple, schema-less dump:
# - standard basic-info rows (Name / Description / Platform / Profile type /
# Created / Modified / Version) via the shared Add-Basic* helpers
# - one settings row per non-internal property + value
# - object-valued properties recurse into named sub-levels: the parent name
# becomes the Category (depth 1) then SubCategory (depth 2). Deeper objects,
# and any array-of-objects, are emitted as a single compact-JSON value (the
# output model only has two named levels; HTML/MD/Word still indent by Level).
# - arrays of scalars are joined; empty arrays / null / empty values are skipped
# - secret-looking properties are redacted; very long strings are truncated
#
# Scope tags + assignments are NOT added here - the engine runs its existing
# Add-ScopeTagsBasicInfoIfApplicable / Add-AssignmentsForObjectIfApplicable
# post-steps after this returns (BasicInfo is populated, so they are not no-ops).
#
# Opt-in: the engine only calls this when Options.FallbackDocumentation is $true.
$script:_fallbackSecretRegex = '(?i)(password|secret|privatekey|private_key|pfxblob|clientsecret|encryptionkey|\bpfx\b)'
$script:_fallbackExcludedNames = @(
'id', 'createdDateTime', 'lastModifiedDateTime', 'modifiedDateTime', 'version',
'roleScopeTagIds', 'roleScopeTags', 'assignments', 'supportsScopeTags',
# already emitted as basic-info rows by Add-BasicDefaultValues
'displayName', 'name', 'description'
)
$script:_fallbackMaxNamedDepth = 2 # Category + SubCategory; deeper -> compact JSON
$script:_fallbackMaxStringLen = 2000 # truncate longer string values
function Get-FallbackDisplayName {
param([string]$PropName)
if (-not $PropName) { return $PropName }
$s = [regex]::Replace($PropName, '([a-z0-9])([A-Z])', '$1 $2') # camelCase -> camel Case
$s = [regex]::Replace($s, '([A-Z]+)([A-Z][a-z])', '$1 $2') # ABCWord -> ABC Word
$s = ($s -replace '[_\-]', ' ').Trim()
if ($s.Length -gt 0) { $s = $s.Substring(0, 1).ToUpper() + $s.Substring(1) }
return $s
}
function Test-FallbackExcluded {
param([string]$Name, $RemoveList)
if (-not $Name) { return $true }
if ($Name -like '*@odata*') { return $true }
if ($Name.StartsWith('#')) { return $true }
if ($script:_fallbackExcludedNames -contains $Name) { return $true }
if ($RemoveList -and ($RemoveList -contains $Name)) { return $true }
return $false
}
function Test-FallbackIsObject {
param($Value)
return ($Value -is [System.Management.Automation.PSCustomObject] -or $Value -is [hashtable])
}
function Test-FallbackIsArray {
param($Value)
return ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string])
}
function Format-FallbackScalar {
param($Value, [string]$Name)
if ($null -eq $Value) { return $null }
if ($Name -match $script:_fallbackSecretRegex) { return '*** redacted ***' }
if ($Value -is [bool]) { return ([bool]$Value).ToString() }
if ($Value -is [datetime]) { return (Format-BasicDateValue $Value) }
$s = [string]$Value
if ($s.Length -gt $script:_fallbackMaxStringLen) {
$s = $s.Substring(0, $script:_fallbackMaxStringLen) + ' ... (truncated)'
}
return $s
}
function Add-FallbackRow {
param([string]$Name, $Value, $RawValue, [string]$Category, [string]$SubCategory, [int]$Level, [string]$EntityKey)
$ctx = Get-CurrentDocumentationContext
$ctx.AddSetting([PSCustomObject]@{
Name = $Name; Value = $Value; Category = $Category; SubCategory = $SubCategory
Level = $Level; RawValue = $RawValue; EntityKey = $EntityKey
})
}
# Recursively walk an object's properties, emitting one row per non-internal
# property. Scalars are emitted before nested objects at each level so flat
# properties group above their sub-sections.
function Add-FallbackProperties {
param($Obj, [int]$Level, [string]$Category, [string]$SubCategory, [string]$PathPrefix, $RemoveList)
if ($null -eq $Obj) { return }
$candidates = @()
foreach ($p in $Obj.PSObject.Properties) {
if (Test-FallbackExcluded $p.Name $RemoveList) { continue }
if ($null -eq $p.Value) { continue }
$candidates += $p
}
# Pass 1: scalars + arrays (leaf rows). Pass 2: nested objects (sub-levels).
$leaves = @($candidates | Where-Object { -not (Test-FallbackIsObject $_.Value) })
$nested = @($candidates | Where-Object { (Test-FallbackIsObject $_.Value) })
foreach ($p in $leaves) {
$name = $p.Name
$val = $p.Value
$display = Get-FallbackDisplayName $name
$entityKey = if ($PathPrefix) { "$PathPrefix.$name" } else { $name }
if ($name -match $script:_fallbackSecretRegex) {
Add-FallbackRow $display '*** redacted ***' $null $Category $SubCategory $Level $entityKey
continue
}
if (Test-FallbackIsArray $val) {
$items = @($val)
if ($items.Count -eq 0) { continue }
$hasComplex = $false
foreach ($it in $items) { if ((Test-FallbackIsObject $it) -or (Test-FallbackIsArray $it)) { $hasComplex = $true; break } }
if ($hasComplex) {
Add-FallbackRow $display ($val | ConvertTo-Json -Depth 20 -Compress) $val $Category $SubCategory $Level $entityKey
}
else {
$joined = ($items | ForEach-Object { Format-FallbackScalar $_ $name }) -join ([Environment]::NewLine)
if ($joined) { Add-FallbackRow $display $joined $val $Category $SubCategory $Level $entityKey }
}
continue
}
$fv = Format-FallbackScalar $val $name
if ($null -eq $fv -or "$fv" -eq '') { continue }
Add-FallbackRow $display $fv $val $Category $SubCategory $Level $entityKey
}
foreach ($p in $nested) {
$name = $p.Name
$val = $p.Value
$display = Get-FallbackDisplayName $name
$entityKey = if ($PathPrefix) { "$PathPrefix.$name" } else { $name }
if ($Level -lt $script:_fallbackMaxNamedDepth) {
$childCat = if ($Level -eq 0) { $display } else { $Category }
$childSub = if ($Level -eq 1) { $display } else { $SubCategory }
Add-FallbackProperties $val ($Level + 1) $childCat $childSub $entityKey $RemoveList
}
else {
Add-FallbackRow $display ($val | ConvertTo-Json -Depth 20 -Compress) $val $Category $SubCategory $Level $entityKey
}
}
}
function Invoke-GenericFallbackDocumentation {
param($PolicyObject, [DocumentationContext]$Context)
Set-CurrentDocumentationContext $Context
# Standard header rows (Name / Description / Platform / Profile type, then
# Created / Modified / Version). Populating BasicInfo also un-gates the
# engine's scope-tag and assignment post-steps.
Add-BasicDefaultValues $PolicyObject
Add-BasicAdditionalValues $PolicyObject
$obj = if ($PolicyObject.PSObject.Properties['JsonObject'] -and $PolicyObject.JsonObject) {
$PolicyObject.JsonObject
} else {
$PolicyObject
}
$removeList = $null
if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.PSObject.Properties['_PropertiesToRemove']) {
$removeList = $PolicyObject.PolicyType._PropertiesToRemove
}
Add-FallbackProperties $obj 0 $null $null '' $removeList
$Context.InputType = 'GenericFallback'
}
# Register the generic fallback as the LAST input provider (Order = MaxValue) so
# it only ever sees objects that no specific provider claimed. Its Match honors
# the opt-in Options.FallbackDocumentation flag - when off, it declines and the
# object falls through to the engine's NoProvider stub, exactly as before.
function Invoke-InitializeGenericFallbackInput {
Add-DocumentationInputProvider ([PSCustomObject]@{
Name = 'GenericFallback'
Order = [int]::MaxValue
Match = {
param($PolicyObject, $Context)
return [bool]($Context -and $Context.Options -and $Context.Options.FallbackDocumentation -eq $true)
}
Translate = { param($PolicyObject, $Context) Invoke-GenericFallbackDocumentation $PolicyObject $Context }
})
}
Invoke-InitializeGenericFallbackInput
@@ -0,0 +1,112 @@
# Shared dispatch for custom behavior used during schema-driven ObjectInfo walks.
# Whole-object handlers are intentionally separate: registering a handler prevents
# Manifest/Profile fallback, while these customizers augment that fallback.
function Add-DocumentationObjectInfoCustomizer {
param([Parameter(Mandatory)][PSCustomObject]$Customizer)
[DocumentationRegistry]::RegisterObjectInfoCustomizer($Customizer)
}
function Get-DocumentationObjectInfoType {
param($Obj)
if (-not $Obj) { return $null }
return [string]$Obj.'@odata.type'
}
function Get-DocumentationObjectInfoCustomizers {
param($Obj)
$ctx = Get-CurrentDocumentationContext
$topObj = if ($ctx.CurrentObject) { $ctx.CurrentObject } else { $Obj }
return @([DocumentationRegistry]::FindObjectInfoCustomizers((Get-DocumentationObjectInfoType $topObj)))
}
function Initialize-DocumentationObjectInfoObject {
param($Obj)
$ctx = Get-CurrentDocumentationContext
foreach ($customizer in (Get-DocumentationObjectInfoCustomizers $Obj)) {
if ($customizer.InitializeObject) {
& $customizer.InitializeObject $Obj $ctx
}
}
}
function Invoke-DocumentationObjectInfoGetPropertyObject {
param($Obj, $Prop)
$ctx = Get-CurrentDocumentationContext
$topObj = $ctx.CurrentObject
foreach ($customizer in (Get-DocumentationObjectInfoCustomizers $Obj)) {
if (-not $customizer.GetPropertyObject) { continue }
$ret = & $customizer.GetPropertyObject $topObj $Obj $Prop $ctx
if ($ret) { return $ret }
}
return $Obj
}
function Invoke-DocumentationObjectInfoGetChildObject {
param($Obj, $Prop)
$ctx = Get-CurrentDocumentationContext
$topObj = $ctx.CurrentObject
foreach ($customizer in (Get-DocumentationObjectInfoCustomizers $Obj)) {
if (-not $customizer.GetChildObject) { continue }
$ret = & $customizer.GetChildObject $topObj $Obj $Prop $ctx
if ($ret) { return $ret }
}
return $Obj
}
function Invoke-DocumentationObjectInfoGetProfileValue {
param($Obj, $Prop)
$ctx = Get-CurrentDocumentationContext
$topObj = $ctx.CurrentObject
foreach ($customizer in (Get-DocumentationObjectInfoCustomizers $Obj)) {
if (-not $customizer.GetProfileValue) { continue }
$ret = & $customizer.GetProfileValue $topObj $Obj $Prop $ctx
if ($null -ne $ret) { return $ret }
}
return $null
}
function Invoke-DocumentationObjectInfoPostAddValue {
param($Prop)
$ctx = Get-CurrentDocumentationContext
$topObj = $ctx.CurrentObject
foreach ($customizer in (Get-DocumentationObjectInfoCustomizers $topObj)) {
if ($customizer.PostAddValue) {
& $customizer.PostAddValue $topObj $Prop $ctx
}
}
}
function Finalize-DocumentationObjectInfoObject {
param($Obj)
$ctx = Get-CurrentDocumentationContext
foreach ($customizer in (Get-DocumentationObjectInfoCustomizers $Obj)) {
if ($customizer.FinalizeObject) {
& $customizer.FinalizeObject $Obj $ctx
}
}
}
# Translates the id of Reusable Settings in Settings Catalog policies to their display name
function Invoke-DocumentationSettingsCatalogPostProcess {
param($Obj, [DocumentationContext]$Context)
if (-not $Obj.templateReference.templateId -or
-not ([string]$Obj.templateReference.templateId).StartsWith('19c8aa67-f286-4861-9aa0-f23541d31680')) {
return
}
# Reusable settings are resolved by id from the source tenant (source-specific).
if ($Context.SourceTenantUnavailable -or -not (Test-DocumentationGraphAvailable)) {
return
}
foreach ($setting in @($Context.SettingsData | Where-Object SettingId -EQ 'vendor_msft_firewall_mdmstore_firewallrules_{firewallrulename}_remoteaddressdynamickeywords')) {
if (-not $setting.RawValue) { continue }
try {
$reusable = Invoke-MSGraphAPI -Url "/deviceManagement/reusablePolicySettings/$($setting.RawValue)"
if ($reusable.displayName) { $setting.Value = $reusable.displayName }
else { Write-Log "No Reusable Settings object found with ID $($setting.RawValue)" 2 }
}
catch {
Write-LogError "Failed to resolve reusable setting $($setting.RawValue)" $_.Exception
}
}
}
@@ -0,0 +1,521 @@
# ObjectInfo JSON walker — the core of the generic Profile input provider.
#
# Ported from old Extensions/Documentation.psm1:2276 (Invoke-TranslateSection,
# ~440 LOC) plus the Invoke-VerifyCondition helper (~70 LOC) and
# Get-CultureLanguageString (~50 LOC).
#
# Drives schema-driven translation for ~140 policy types catalogued in
# Config/ObjectCategories.json. Each ObjectInfo JSON file under
# Config/ObjectInfo/<category>_<policyType>.json describes the per-property
# metadata (dataType, entityKey, nameResourceKey, child layout) and the
# walker dispatches each property to the appropriate translate primitive
# based on dataType.
# ---- Section walker ----
# Tracks the parent prop being walked so propLevel adjusts correctly when
# recursing into children. Module-scope (replaces old $script:currentParent).
$script:_currentSectionParent = $null
# Defaults: properties whose nameResourceKey shows up in this list are skipped
# entirely (purely visual elements in the old portal that don't translate
# to documentation content). Old code at Documentation.psm1:2977.
# ToDo: Review if these should be implemented or actually ignored
$script:_categoriesToIgnore = @(
'defenderSecurityCenterContactOptionsText'
'globalConfigurationsDescription','generalNetworkSettingsHeader'
'firewallCreateRules','exploitGuardCFHeadingText','exploitGuardNFTitle'
'exploitGuardEPExplainationPart1','exploitGuardEPExplainationPart2'
'exploitGuardEPExplainationPart3','exploitGuardEPExplainationPart4'
'defenderSecurityCenterSubHeaderText','defenderSecurityCenterITContactInformationSubHeaderText'
'windows10EndpointProtectionDeviceGuardLearnMore'
'win10DefaultPrivacyHeader','dfciBuiltinHeaderDescName'
)
# Resource keys that upstream renamed while the blade metadata kept referencing
# the old name. Microsoft's own portal cannot render these tooltips either, so
# there is nothing to wait for - map them to the current name.
# Confirmed 2026-08-22 by the IntuneLanuageAndObjects generator, which extracts
# the portal's ClientResources verbatim.
$script:_resourceKeyAliases = @{
'autoInstallAndRebootAtScheduledTime' = 'autoInstallAndRebootAtScheduledTimeOption'
# Only connecteddevices_iosgeneral.json references this, so the iOS variant
# is the correct target; a MacOS variant also exists upstream.
'blockAirPrintiBeaconDiscoveryDescription' = 'blockAirPrintiBeaconDiscoveryDescriptionIOS'
}
# Resolve an ObjectInfo resource key to its display string, or $null.
#
# Keys without a namespace live under SettingDetails. Two upstream quirks are
# handled here so callers do not each reimplement them:
#
# - Purely numeric keys are portal metadata artifacts, not string ids. The
# AndroidDeviceOwner and AOSP PKCS files carry emptyValueResourceKey:"1"
# verbatim from the blade metadata, which resolves to nothing and logs a
# "Could not find string" warning on every documented policy. Skipped the way
# the existing 'Empty' / 'LearnMore' sentinels are.
# - Renamed keys are redirected via $script:_resourceKeyAliases.
function Get-ObjectInfoResourceString {
param(
[string]$Key,
# emptyValueResourceKey values are already fully qualified upstream and
# must not get the SettingDetails prefix.
[switch]$NoPrefix
)
if ([string]::IsNullOrWhiteSpace($Key)) { return $null }
if ($Key -match '^\d+$') { return $null }
if ($script:_resourceKeyAliases.ContainsKey($Key)) { $Key = $script:_resourceKeyAliases[$Key] }
$full = if ($NoPrefix -or $Key.Contains('.')) { $Key } else { "SettingDetails.$Key" }
try { return Get-LanguageString $full }
catch {
Write-Log "Get-LanguageString '$full' failed: $($_.Exception.Message)" 2
return $null
}
}
# Walk a flat settings object through an ObjectInfo manifest file. Used by the
# AppConfig handlers to give Outlook/Edge their schema-driven rows (the old code
# called Invoke-TranslateSection directly against #AppConfig*.json). $ManifestPath
# is a full path under Config\ObjectInfo\.
function Invoke-DocAppConfigManifest {
param($SettingsObject, [string]$ManifestPath, [DocumentationContext]$Context)
if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { return }
try {
$jsonObj = [IO.File]::ReadAllText($ManifestPath) | ConvertFrom-Json
}
catch {
Write-LogError "Failed to read AppConfig manifest $ManifestPath" $_.Exception
return
}
if (-not $jsonObj) { return }
$prev = $Context.CurrentObject
$Context.CurrentObject = $SettingsObject
try { Invoke-TranslateSection $SettingsObject $jsonObj $null }
catch { Write-LogError "Failed to translate AppConfig manifest $(Split-Path -Leaf $ManifestPath)" $_.Exception }
finally { $Context.CurrentObject = $prev }
}
function Invoke-TranslateSection {
param($Obj, $SectionObject, $ObjInfo, $Parent = $null)
$ctx = Get-CurrentDocumentationContext
# Reset/adjust propLevel based on whether we're a new walk or recursing
if ($null -eq $Parent -or $ctx.PropLevel -lt 0) {
$ctx.PropLevel = 0
}
elseif ($Parent -ne $script:_currentSectionParent) {
$ctx.PropLevel++
}
foreach ($prop in $SectionObject) {
$value = $null
$valueSet = $false
$useParentProp = $false
$payloadFile = $false
$skipChildren = $false
if (-not (Invoke-VerifyCondition $Obj $prop $ObjInfo)) {
Write-LogDebug "Condition returned false: $($prop.Condition | ConvertTo-Json -Depth 50 -Compress)"
continue
}
$Obj = Get-CustomPropertyObject $Obj $prop
$rawValue = $Obj."$($prop.entityKey)"
# ---- Section/category headers (dataType 8) ----
if ($prop.dataType -eq 8) {
if ($prop.nameResourceKey -eq 'LearnMore') { continue }
elseif ($prop.nameResourceKey -eq 'Empty') { $ctx.CurrentSubCategory = $null }
elseif ($prop.nameResourceKey -in $script:_categoriesToIgnore) { continue }
elseif ($prop.nameResourceKey) {
$key = if ($prop.nameResourceKey.Contains('.')) { $prop.nameResourceKey } else { "SettingDetails.$($prop.nameResourceKey)" }
$tmpStr = Get-LanguageString $key
if ($tmpStr -and $tmpStr.Length -lt 75) {
$ctx.CurrentSubCategory = $tmpStr
}
elseif ($tmpStr) {
Write-LogDebug "SubCategory ignored based on length: $tmpStr"
}
}
$ctx.PropLevel = -1
Invoke-ChildSections $Obj $prop
# A header without child sections leaves the -1 reset sentinel dangling;
# the next property's childSettings recursion would then reset to level 0
# instead of indenting one level under its parent row. Normalize here so
# only the header's own children get the flat-level reset.
if ($ctx.PropLevel -lt 0) { $ctx.PropLevel = 0 }
continue
}
# ---- Complex options (dataType 5) ----
if ($prop.dataType -eq 5) {
if ($prop.enabled -eq $false -and $ObjInfo.ShowDisabled -ne $true) { continue }
if (-not $prop.EntityKey -and $prop.nameResourceKey) {
$ctx.PropLevel = -1
$key = if ($prop.nameResourceKey.Contains('.')) { $prop.nameResourceKey } else { "SettingDetails.$($prop.nameResourceKey)" }
$ctx.CurrentSubCategory = Get-LanguageString $key
}
else {
$ctx.PropLevel--
}
foreach ($tmpObj in $Obj) {
Invoke-TranslateSection $tmpObj $prop.complexOptions $ObjInfo -Parent $prop
}
continue
}
# ---- Complex option based on sub-property (dataType 6) ----
if ($prop.dataType -eq 6) {
if ($prop.enabled -eq $false -and $ObjInfo.ShowDisabled -ne $true) { continue }
$ctx.PropLevel--
$propObj = $null
if ($prop.entityKey) { $propObj = $Obj.PSObject.Properties | Where-Object Name -EQ $prop.entityKey }
$iter = if ($null -ne $propObj) { $rawValue } else { $Obj }
foreach ($tmpObj in $iter) {
Invoke-TranslateSection $tmpObj $prop.complexOptions $ObjInfo -Parent $prop
}
continue
}
# ---- Skip-but-add-children label (dataType 9) ----
if ($prop.dataType -eq 9) {
$ctx.PropLevel--
Invoke-ChildSections $Obj $prop
continue
}
# ---- Information box: ignore (dataType 10) ----
if ($prop.dataType -eq 10) { continue }
# ---- Static-string label (dataType 101): language-id lookup ----
if ($prop.dataType -eq 101) {
if ($prop.value) {
$value = Get-LanguageString $prop.value
Add-PropertyInfo $prop $value $rawValue $rawValue
}
continue
}
# ---- Static value (dataType 107) ----
if ($prop.dataType -eq 107) {
if ($prop.value) {
Add-PropertyInfo $prop $prop.value $prop.value $prop.value
}
continue
}
# ---- Generic property path (dataType varies, requires entityKey) ----
if (-not [string]::IsNullOrEmpty($prop.entityKey)) {
$valueSet = ($null -ne $rawValue)
# Determine propValue (with defaults fallback). Old engine gates the
# unconfigured/default substitutions on $global:chk* UI checkboxes
# which default to UNCHECKED — meaning when a property is null on
# the input, the walker just uses null (and most translate primitives
# then either skip the row or emit "Not configured" via their own
# logic). My port honors that by gating on $ctx.Options.SetUnconfigured
# Value / SetDefaultValue (also default false).
$propValue = if ($null -ne $rawValue) { $rawValue }
elseif (-not [string]::IsNullOrEmpty($prop.unconfiguredValue) -and $ctx.Options.SetUnconfiguredValue) {
Add-NotConfiguredProperty $prop
$prop.unconfiguredValue
}
elseif (-not [string]::IsNullOrEmpty($prop.defaultValue) -and $ctx.Options.SetDefaultValue) {
$prop.defaultValue
}
elseif (-not [string]::IsNullOrEmpty($prop.emptyValueResourceKey) -and $ctx.Options.SetDefaultValue) {
Get-ObjectInfoResourceString $prop.emptyValueResourceKey -NoPrefix
}
else { $rawValue }
$addPropertyInfo = $true
$customValue = Get-CustomProfileValue $Obj $prop
if ($customValue -is [bool] -and $customValue -eq $false) {
continue
}
elseif (-not $customValue) {
# Linked certificate (dataType 4): live Graph navigationLink
# Stub offline — uses #CustomRef_ embedded data when present
if ($prop.dataType -eq 4) {
$useParentProp = $true
$cert = $null
if (-not $ctx.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
try {
$url = $ctx.CurrentObject."$($prop.entityKey)@odata.navigationLink"
if ($url) {
# Most policies advertise the navigationLink even when no
# certificate is associated; the GET 404s. Cache the
# outcome on $ctx so the second walk of the same policy
# (the schema lists the same entityKey twice for the
# SCEP+PKCS+derived flavours) and any later policies in
# the bulk run skip a known-empty fetch.
if (-not $ctx.PSObject.Properties['_LinkedCertCache']) {
$ctx | Add-Member -MemberType NoteProperty -Name '_LinkedCertCache' -Value (@{}) -Force
}
if ($ctx._LinkedCertCache.ContainsKey($url)) {
$cert = $ctx._LinkedCertCache[$url]
}
else {
try {
$cert = Invoke-MSGraphAPI -Url $url -ODataMetadata 'minimal' -NoError
} catch { $cert = $null }
$ctx._LinkedCertCache[$url] = $cert
}
}
} catch { }
}
if ($cert) {
if ($cert.value -is [object[]]) {
$certs = @($cert.value | ForEach-Object { $_.displayName }) | Where-Object { $_ }
if ($certs.Count -gt 0) { $value = $certs -join $ctx.ObjectSeparator }
}
elseif ($cert.displayName) {
$value = $cert.displayName
}
$rawValue = $value
}
elseif ($ctx.CurrentObject.'@ObjectFromFile' -eq $true -or $ctx.SourceTenantUnavailable) {
$refKey = "#CustomRef_$($prop.entityKey)"
if ($ctx.CurrentObject.$refKey) {
$sep = $ctx.CurrentObject.$refKey.IndexOf('|:|')
$value = if ($sep -gt -1) { $ctx.CurrentObject.$refKey.Substring(0, $sep) } else { $ctx.CurrentObject.$refKey }
}
$rawValue = $value
}
}
# Multi-option based on boolean value where the property name IS the key (dataType 200)
elseif ($prop.dataType -eq 200) {
$value = Get-LanguageString $prop.entityKey
}
# Property missing on the object (and not "allowMissing")
elseif (-not $prop.allowMissing -and
$prop.entityKey -ne '.' -and
-not ($Obj.PSObject.Properties | Where-Object Name -EQ $prop.entityKey) -and
-not ($Obj.PSObject.Properties | Where-Object Name -EQ "$($prop.entityKey)@odata.navigationLink")) {
if ($prop.enabled -ne $false) {
Write-Log "Property with EntityKey $($prop.entityKey) is missing. Property will not be added!" 2
}
else {
Write-LogDebug "Disabled property with EntityKey $($prop.entityKey) is missing. Property will not be added!"
}
continue
}
else {
# NOTE: `continue` inside `switch` only goes to the next
# switch case match in PowerShell — it does NOT skip code
# after the switch. Cases that handle their own row emission
# (Option / Table) must set $addPropertyInfo = $false so the
# Add-PropertyInfo call below is skipped. (Earlier port used
# `continue` here and produced duplicate rows.)
switch ([int]$prop.dataType) {
0 { $value = Invoke-TranslateBoolean $Obj $prop }
1 {
# Base64 e.g. certificate data
$value = if ($prop.filenameEntityKey -and $Obj."$($prop.filenameEntityKey)") {
$Obj."$($prop.filenameEntityKey)"
} else {
$v = $Obj."$($prop.EntityKey)"
if ($v) { try { [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($v)) } catch { $v } } else { $v }
}
}
2 {
# Multiline string (often a base64-wrapped XML payload file)
if ($prop.filenameEntityKey -and $Obj."$($prop.filenameEntityKey)") {
$value = $Obj."$($prop.filenameEntityKey)"
$payloadFile = $true
}
else {
$v = $Obj."$($prop.EntityKey)"
$value = if ($v) { try { [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($v)) } catch { $v } } else { $v }
}
}
3 {
# Image — placeholder label; raw image data dropped (no consumer yet).
$value = if ($propValue) { 'Image file' } else { $null }
}
7 { $value = $propValue } # omaSettingDateTime — formatting deferred
11 { } # App picker — value left $null
12 {
# Multiline string / array
if (($propValue | Measure-Object).Count -gt 0) {
$value = $propValue -join $ctx.ObjectSeparator
}
}
13 { $value = Invoke-TranslateMultiOption $Obj $prop }
14 { $value = $propValue } # Int32
15 { $value = $propValue } # Int64
16 { Invoke-TranslateOption $Obj $prop | Out-Null; $addPropertyInfo = $false; $skipChildren = $true }
19 { Invoke-TranslateOption $Obj $prop | Out-Null; $addPropertyInfo = $false; $skipChildren = $true }
20 { $value = $propValue } # String
21 { Invoke-TranslateTable $Obj $prop; $addPropertyInfo = $false; $skipChildren = $true }
22 {
# Scale value e.g. "4 Years"
$value = $propValue
$scaleEntityKey = if ($Obj."$($prop.scaleEntityKey)") { $Obj."$($prop.scaleEntityKey)" } else { $prop.defaultScale }
if ($scaleEntityKey) {
$scaleOption = $prop.scaleOptions | Where-Object value -EQ $scaleEntityKey | Select-Object -First 1
if ($scaleOption.nameResourceKey) {
$value = '{0} {1}' -f $propValue, (Get-LanguageString "SettingDetails.$($scaleOption.nameResourceKey)")
}
}
}
100 { $value = Invoke-TranslateDuration $Obj $prop }
102 {
$culture = if ($propValue) { $propValue } else { $prop.unconfiguredValue }
$value = Get-CultureLanguageString $culture
}
103 {
# Boolean action but hide children on false
$value = Invoke-TranslateBoolean $Obj $prop
$skipChildren = ($propValue -eq $false)
}
104 {
$value = Invoke-TranslateMultiOptionBoolean $Obj $prop
$skipChildren = ($propValue -eq $false)
}
105 {
$value = Invoke-TranslateMultiOptionBoolean $Obj $prop $false
$skipChildren = ($propValue -eq $false)
}
106 {
# Array of cultures
$tmp = @()
foreach ($lng in $propValue) { $tmp += Get-CultureLanguageString $lng }
$value = $tmp -join $ctx.ObjectSeparator
}
108 {
# String with format
$value = $propValue
if ($prop.formatStringKey) {
$fmt = Get-LanguageString $prop.formatStringKey
if ($fmt) { $value = $fmt -f $propValue }
}
}
default {
$nameForLog = if ($prop.nameResourceKey) { Get-LanguageString "SettingDetails.$($prop.nameResourceKey)" } else { '' }
Write-Log "Unsupported property '$nameForLog' ($($prop.nameResourceKey)) for object property $($prop.entityKey). Type: $($prop.dataType)" 2
$value = $propValue
}
}
}
}
else {
$value = $customValue.Value
$rawValue = $customValue.RawValue
$valueSet = ($null -ne $rawValue)
$addPropertyInfo = $customValue.AddPropertyInfo
}
if ($addPropertyInfo) {
$propForAdd = if ($useParentProp -and $Parent) { $Parent } else { $prop }
Add-PropertyInfo $propForAdd $value $rawValue
if ($payloadFile -and $Obj.payload) {
$tmpProp = [PSCustomObject]@{
nameResourceKey = 'uploadResult'
descriptionResourceKey = ''
entityKey = 'payloadData'
dataType = 20
booleanActions = 0
category = $prop.Category
}
$payloadValue = try { [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Obj.payload)) } catch { $Obj.payload }
Add-PropertyInfo $tmpProp $payloadValue $Obj.payload
}
}
}
else {
Write-Log "No property entity key: $($prop.dataType) ($($prop.nameResourceKey))" 2
}
if ($valueSet -and -not $skipChildren) {
Invoke-ChildSections $Obj $prop
}
}
if ($null -ne $Parent -and $Parent -ne $script:_currentSectionParent -and $ctx.PropLevel -gt 0) {
$ctx.PropLevel--
}
}
# ---- Condition verifier (dataType-independent property gate) ----
function Invoke-VerifyCondition {
param($Obj, $Prop, $ObjInfo)
if (-not $Prop.Condition -or ($Prop.Condition.Expressions | Measure-Object).Count -eq 0) { return $true }
$type = if ($Prop.Condition.type -eq 'and') { 'and' } else { 'or' }
$defaultReturn = ($type -eq 'and')
foreach ($expression in $Prop.Condition.Expressions) {
if (-not $expression.property) { continue }
$tmpProp = $Obj.PSObject.Properties | Where-Object Name -EQ $expression.property
if (-not $tmpProp) {
if ($expression.ignoreMissing -eq $true) { continue }
return $false
}
$tmpRet = switch ($expression.operator) {
'null' { $null -eq $tmpProp.Value }
'ne' { $Obj."$($expression.property)" -ne $expression.value }
'gt' { $Obj."$($expression.property)" -gt $expression.value }
'ge' { $Obj."$($expression.property)" -ge $expression.value }
'lt' { $Obj."$($expression.property)" -lt $expression.value }
'le' { $Obj."$($expression.property)" -le $expression.value }
'like' { $Obj."$($expression.property)" -like $expression.value }
'notlike' { $Obj."$($expression.property)" -notlike $expression.value }
default {
if ($null -eq $expression.value) {
$null -ne $tmpProp.Value
}
else {
$Obj."$($expression.property)" -eq $expression.value
}
}
}
if ($tmpRet -eq $true -and $type -eq 'or') { return $true }
if ($tmpRet -eq $false -and $type -eq 'and') { return $false }
}
return $defaultReturn
}
# ---- Culture-code -> language name ----
# Used by dataType 102 (Culture name) and 106 (Array of languages).
# Looks up Languages.<culture> in the loaded Strings-en.json; falls back to
# the OS culture's EnglishName.
function Get-CultureLanguageString {
param($Culture)
if (-not $Culture) { return $null }
try {
if ($Culture -eq 'os-default') { return Get-LanguageString 'Autopilot.OOBE.useOSDefaultLanguage' }
if ($Culture -eq 'user-select') { return Get-LanguageString 'Autopilot.OOBE.userSelect' }
# Force language strings to load by calling Get-LanguageString once
Get-LanguageString $null | Out-Null
$cache = Get-CacheObject "LanguageStrings_$($Culture)"
if (-not $cache) { $cache = Get-CacheObject 'LanguageStrings_en' }
if ($cache.Languages.$Culture) { return $cache.Languages.$Culture }
$parts = $Culture.Split('-')
if ($parts.Length -eq 3) {
$tri = "$($parts[0])-$($parts[1])"
if ($cache.Languages.$tri) { return $cache.Languages.$tri }
}
if ($parts.Length -gt 1 -and $cache.Languages."$($parts[0])") {
return $cache.Languages."$($parts[0])"
}
Write-Log "Translated language for $Culture not found" 2
return ([cultureinfo]$Culture).EnglishName
}
catch { return $null }
}
@@ -0,0 +1,345 @@
# Settings Catalog walker.
#
# Ported from old Extensions/Documentation.psm1:1210 (Add-SettingsSetting,
# ~230 LOC). Recursive walker over the deviceManagementConfigurationSetting
# tree — handles 6 settingInstance variants:
# - SimpleSettingInstance (string/int value)
# - ChoiceSettingInstance (single dropdown, may have child settings)
# - ChoiceSettingCollectionInstance (multi-select dropdown)
# - GroupSettingCollectionInstance (table-like rows of grouped sub-settings)
# - SimpleSettingCollectionInstance (list of simple values)
# - GroupSettingInstance (single group container — emits only children)
#
# Settings catalog state on the context:
# $ctx.CachedCfgSettings - settingDefinitionId -> full definition object
# $ctx.CfgCategories - flat list of category objects
# $script:_curSettingsCatologPolicy - per-policy buffer of settingInfo rows
# (drained by the input provider into $ctx.SettingsData in category order)
$script:_curSettingsCatologPolicy = @()
function Reset-SettingsCatalogPolicyBuffer {
$script:_curSettingsCatologPolicy = @()
}
function Get-SettingsCatalogPolicyBuffer {
return $script:_curSettingsCatologPolicy
}
function Add-SettingsSetting {
param(
$SettingInstance,
$SettingsDefs,
[int]$ItemLevel = 0,
[switch]$SkipAdd
)
if (-not $SettingInstance) { return }
$ctx = Get-CurrentDocumentationContext
$defaultValue = $null
$tableValue = $null
$value = $null
$rawValue = $null
$rawJsonValue = $null
$show = $true
$childSettings = @()
# Look up the settings definition: prefer inline ($expand=settingDefinitions
# exports), then context cache, then live Graph as last resort. The live
# endpoint (configurationSettings/{id}) is GENERIC schema - identical on every
# tenant - so it is gated only on connectivity (Test-DocumentationGraphAvailable),
# NOT on SourceTenantUnavailable: documenting an export while signed into a
# different tenant must still resolve setting names.
$settingsDef = $null
if ($SettingsDefs) {
$settingsDef = $SettingsDefs | Where-Object id -EQ $SettingInstance.settingDefinitionId | Select-Object -First 1
}
if (-not $settingsDef -and $SettingInstance.settingDefinitionId) {
if ($ctx.CachedCfgSettings.ContainsKey($SettingInstance.settingDefinitionId)) {
$settingsDef = $ctx.CachedCfgSettings[$SettingInstance.settingDefinitionId]
}
elseif (Test-DocumentationGraphAvailable) {
try {
$settingsDef = Invoke-MSGraphAPI -Url "/deviceManagement/configurationSettings/$($SettingInstance.settingDefinitionId)" -AdditionalHeaders (Get-DocAcceptLanguageHeaders $ctx)
if ($settingsDef) {
$ctx.CachedCfgSettings[$SettingInstance.settingDefinitionId] = $settingsDef
}
}
catch {
Write-LogError "Failed to fetch settings catalog definition for $($SettingInstance.settingDefinitionId)" $_.Exception
}
}
}
# Category lookup: root category becomes Category, leaf becomes SubCategory
$categoryDef = $null
$objCategory = $null
$subCategory = $null
if ($settingsDef.categoryId) {
$categoryDef = $ctx.CfgCategories | Where-Object Id -EQ $settingsDef.categoryId | Select-Object -First 1
if ($categoryDef -and $settingsDef.categoryId -ne $categoryDef.rootCategoryId) {
$objCategory = $ctx.CfgCategories | Where-Object Id -EQ $categoryDef.rootCategoryId | Select-Object -First 1
$subCategory = $categoryDef
}
else {
$objCategory = $categoryDef
}
}
$settingName = ''
$settingDescription = ''
if ($settingsDef.displayName) {
$settingName = $settingsDef.displayName.Trim([Environment]::NewLine).Trim("`n")
}
if ($settingsDef.description) {
$settingDescription = $settingsDef.description.Trim([Environment]::NewLine).Trim("`n")
}
$settingInfo = [PSCustomObject]@{
SettingId = $settingsDef.Id
SettingKey = ''
SettingName = $settingsDef.Name
Name = $settingName
Description = $settingDescription
CategoryId = $objCategory.id
Category = $objCategory.displayName
CategoryDefinition = $objCategory
SubCategory = $subCategory.displayName
SubCategoryDefinition = $subCategory
Value = $null
RawValue = $null
RawJsonValue = $null
TableValue = $null
DefaultValue = $null
Level = $ItemLevel
Parent = $null
Show = $show
Type = $SettingInstance.'@odata.type'
PropertyIndex = 0
RowIndex = 0
ChildSettings = @()
}
if (-not $SkipAdd) {
$script:_curSettingsCatologPolicy += $settingInfo
}
switch ($SettingInstance.'@odata.type') {
'#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance' {
# Single dropdown
$rawValue = $SettingInstance.choiceSettingValue.value
$opt = $settingsDef.Options | Where-Object itemId -EQ $rawValue | Select-Object -First 1
$value = $opt.displayName
if ($settingsDef.defaultOptionId) {
$defaultValue = ($settingsDef.Options | Where-Object itemId -EQ $settingsDef.defaultOptionId).displayName
}
# Children added to the buffer (NOT -SkipAdd) so the HTML output's
# flat row iterator emits them with `Level` padding under the
# parent. Old code at Documentation.psm1:1300 declared the
# -SkippAdd switch but never honored it, so children were always
# added — matching that behavior here. See [[group-setting-collection-children]].
foreach ($childSetting in $SettingInstance.choiceSettingValue.children) {
$tmp = Add-SettingsSetting $childSetting $SettingsDefs ($ItemLevel + 1)
if ($tmp) { $tmp.Parent = $settingInfo; $settingInfo.ChildSettings += $tmp }
}
}
'#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance' {
# Single primitive value
$value = $SettingInstance.simpleSettingValue.value
$rawValue = $value
if ($settingsDef.defaultValue.value) {
$defaultValue = $settingsDef.defaultValue.value
}
}
'#microsoft.graph.deviceManagementConfigurationChoiceSettingCollectionInstance' {
# Multi-select dropdown
$itemValues = @()
$itemRawValues = @()
foreach ($colObj in $SettingInstance.choiceSettingCollectionValue) {
$itemRawValues += $colObj.value
$opt = $settingsDef.Options | Where-Object itemId -EQ $colObj.Value | Select-Object -First 1
$itemValues += $opt.displayName
}
$value = $itemValues -join $ctx.PropertySeparator
$rawValue = $itemRawValues -join $ctx.PropertySeparator
$rawJsonValue = $SettingInstance.choiceSettingCollectionValue | ConvertTo-Json -Depth 50 -Compress
if ($settingsDef.defaultOptionId) {
$defaultValue = ($settingsDef.Options | Where-Object itemId -EQ $settingsDef.defaultOptionId).displayName
}
}
'#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance' {
# Table-like rows of grouped sub-settings — group row itself isn't shown
$settingInfo.Show = $false
$rowIndex = 1
foreach ($groupSettingCollection in $SettingInstance.groupSettingCollectionValue) {
$childArr = @()
# Endpoint Security templates supply $settingsDefs.id; pure Settings
# Catalog uses $settingsDef.childIds. Old code at L1347-1354.
$childIds = if ($ctx.CurrentObject.templateReference.templateId -and $SettingsDefs) {
$SettingsDefs.id
} else {
$settingsDef.childIds
}
foreach ($childId in $childIds) {
$childSetting = $groupSettingCollection.children | Where-Object settingDefinitionId -EQ $childId | Select-Object -First 1
if (-not $childSetting) { continue }
# Children added to buffer (no -SkipAdd) so the HTML output's
# flat-row iterator can render each one with `Level` padding —
# the parent itself has Show=false above, so only the
# children are visible. Without this, the entire group
# vanishes from output (the Linux 'Allowed Distros' regression).
$tmp = Add-SettingsSetting $childSetting $SettingsDefs ($ItemLevel + 1)
if ($tmp) {
$tmp.Parent = $childSettings
$tmp.RowIndex = $rowIndex
$childSettings += $tmp
$childArr += $tmp
if (($settingsDef.childIds | Measure-Object).Count -gt 1) {
$tmp.PropertyIndex = $childArr.Count
}
}
}
$settingInfo.ChildSettings += [PSCustomObject]@{
Id = $rowIndex++
Type = $groupSettingCollection.'@odata.type'
Settings = $childArr
}
}
$rawJsonValue = $SettingInstance.groupSettingCollectionValue | ConvertTo-Json -Depth 50 -Compress
}
'#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance' {
# List of primitive values
$itemValues = @()
foreach ($colObj in $SettingInstance.simpleSettingCollectionValue) {
$itemValues += $colObj.value
}
if ($settingsDef.defaultValue.value) { $defaultValue = $settingsDef.defaultValue.value }
$value = $itemValues -join $ctx.PropertySeparator
$rawValue = $itemValues -join $ctx.PropertySeparator
$rawJsonValue = $SettingInstance.simpleSettingCollectionValue | ConvertTo-Json -Depth 50 -Compress
}
'#microsoft.graph.deviceManagementConfigurationGroupSettingInstance' {
# Single group container — group itself isn't emitted, only children
$settingInfo.Show = $false
foreach ($groupSettingValue in $SettingInstance.groupSettingValue) {
foreach ($childSetting in $groupSettingValue.children) {
# Same rationale as the GroupSettingCollection case above —
# children must reach the buffer (no -SkipAdd) so they
# render in HTML output once the Show=false parent is dropped.
$tmp = Add-SettingsSetting $childSetting $SettingsDefs ($ItemLevel + 1)
if ($tmp) { $tmp.Parent = $settingInfo; $settingInfo.ChildSettings += $tmp }
}
}
$rawJsonValue = $SettingInstance.groupSettingValue | ConvertTo-Json -Depth 50 -Compress
}
default {
Write-Log "Unhandled settings catalog instance type: $($SettingInstance.'@odata.type')" 2
return
}
}
if (-not $rawJsonValue -and $rawValue) {
$rawJsonValue = $rawValue | ConvertTo-Json -Depth 50 -Compress
}
$settingInfo.Value = $value
$settingInfo.RawValue = $rawValue
$settingInfo.RawJsonValue = $rawJsonValue
$settingInfo.DefaultValue = $defaultValue
return $settingInfo
}
# Resolve a Settings Catalog payload - Collection(deviceManagementConfigurationSetting) -
# into documentation rows, ordered by (Category, SubCategory).
#
# THE single implementation. Two payload shapes carry settings-catalog settings:
# deviceManagement/configurationPolicies (Settings Catalog policies)
# deviceAppManagement/targetedManagedAppConfigurations (the "Settings catalog"
# step of a MAM app config)
# The MAM handler used to keep its own copy of this block, and it had drifted:
# it omitted the configurationCategories fetch below, so category/subcategory
# grouping silently collapsed on any run that had not already documented a
# Settings Catalog policy (making the output order-dependent). Both callers now
# go through here.
#
# Rows are returned rather than pushed onto the context, so the caller decides
# whether they belong in the main settings table or in a table of their own.
function Get-SettingsCatalogDocumentationRows
{
param(
$Settings,
[DocumentationContext]$Context
)
$cfgSettings = @($Settings)
if ($cfgSettings.Count -eq 0) { return @() }
# Generic schema caches (session-persistent, shared by reference so later
# writes by the walker warm the cache automatically). Definitions are generic
# Intune schema, so they persist across runs and tenant switches.
$Context.CachedCfgSettings = Get-CacheObject "DocCfgSettingDefinitions" $Context.CachedCfgSettings
Set-CacheObject "DocCfgSettingDefinitions" $Context.CachedCfgSettings -Persistent
$Context.CfgCategories = Get-CacheObject "CfgCategories" (@())
# Generic schema (configurationCategories) - same on every tenant - so gated
# only on connectivity, not on SourceTenantUnavailable. Without this the
# walker cannot resolve a row's category and the nesting disappears.
if (-not ($Context.CfgCategories | Where-Object { $_.settingUsage -eq 'configuration' }) -and (Test-DocumentationGraphAvailable)) {
try {
Write-Log "Cache Settings Catalog configurationCategories"
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/configurationCategories" -ODataMetadata 'minimal' -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
$Context.CfgCategories += @($resp.Value)
Set-CacheObject "CfgCategories" $Context.CfgCategories -Persistent
}
catch {
Write-LogError 'Failed to fetch configuration categories' $_.Exception
}
}
# Seed the definition cache from inline settingDefinitions on each setting
foreach ($cfgSetting in $cfgSettings) {
if (-not $cfgSetting.settingDefinitions) { continue }
$defObj = $cfgSetting.settingDefinitions | Where-Object id -EQ $cfgSetting.settingInstance.settingDefinitionId | Select-Object -First 1
if ($defObj -and -not $Context.CachedCfgSettings.ContainsKey($defObj.Id)) {
$Context.CachedCfgSettings[$defObj.Id] = $defObj
}
}
# Walk each top-level setting into the shared buffer
Reset-SettingsCatalogPolicyBuffer
foreach ($cfgSetting in $cfgSettings) {
Add-SettingsSetting $cfgSetting.settingInstance $cfgSetting.settingDefinitions | Out-Null
}
# Drain the buffer in (Category, SubCategory) order - this grouping is what
# produces the portal's nesting in the rendered table.
$buffer = Get-SettingsCatalogPolicyBuffer
$unique = $buffer |
Select-Object @{ l='CategoryID'; e={ $_.CategoryDefinition.Id } },
@{ l='SubCategoryID'; e={ $_.SubCategoryDefinition.Id } } -Unique
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($pair in $unique) {
$matching = $buffer | Where-Object {
$_.CategoryDefinition.Id -eq $pair.CategoryID -and
$_.SubCategoryDefinition.Id -eq $pair.SubCategoryID
}
foreach ($row in $matching) {
if ($row.Show -eq $false) { continue }
[void]$rows.Add($row)
}
}
return $rows.ToArray()
}
File diff suppressed because it is too large Load Diff