mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
IntuneManagement 4.0.0-beta1
This commit is contained in:
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
Reference in New Issue
Block a user