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:
@@ -0,0 +1,184 @@
|
||||
<style type="text/css">
|
||||
|
||||
html { }
|
||||
|
||||
html,body {
|
||||
margin:0;
|
||||
padding:5px;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
.header-level1 {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 18px;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.header-level2 {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 16px;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.header-level3 {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 14px;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.header-level4 {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 12px;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.header-level6 {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.table-settings {
|
||||
border: 1px solid #999999;
|
||||
padding-right: 5px;
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.table-settings TR:nth-child(even) {
|
||||
background-color: #FAFAFA
|
||||
}
|
||||
|
||||
.table-settings th {
|
||||
background-color: #D0D0D0;
|
||||
padding: 0px;
|
||||
text-align:left;
|
||||
font-size: 12px;
|
||||
padding-right: 10px;
|
||||
padding-top: 3px;
|
||||
font-weight: bold;
|
||||
border-width: 5px;
|
||||
padding: 5px;
|
||||
border-style: none;
|
||||
border-color: #F2F2F2;
|
||||
}
|
||||
|
||||
.table-settings td {
|
||||
text-align:left;
|
||||
padding-right: 5px;
|
||||
border-width: 5px;
|
||||
padding: 5px;
|
||||
border-style: none;
|
||||
border-color: #F2F2F2;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.code {
|
||||
background: #f4f4f4;
|
||||
border: 1px solid #ddd;
|
||||
border-left: 3px solid #333333;
|
||||
color: #666;
|
||||
page-break-inside: avoid;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2px;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
padding: 1em 1.5em;
|
||||
display: block;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.description summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
details.description[open] summary::after {
|
||||
content: attr(data-open);
|
||||
}
|
||||
|
||||
details.description:not([open]) summary::after {
|
||||
content: attr(data-close);
|
||||
}
|
||||
|
||||
.row-even {
|
||||
|
||||
}
|
||||
|
||||
.row-odd {
|
||||
|
||||
}
|
||||
|
||||
.category-level1 {
|
||||
background-color: #E0E0E0;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.category-level2 {
|
||||
background-color: #E0E0E0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.anchor-style {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.anchor-level2 {
|
||||
|
||||
}
|
||||
|
||||
.anchor-level3 {
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.anchor-level4 {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.anchor-level5 {
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.table-value {
|
||||
border: 0px;
|
||||
width: 100%;
|
||||
padding: 0px;
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.table-value th {
|
||||
background-color: #F9F9F9;
|
||||
padding: 0px;
|
||||
text-align:left;
|
||||
font-size: 11px;
|
||||
padding-right: 0px;
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
font-weight: normal;
|
||||
border-width: 0px;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
.table-value tr {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.table-value td {
|
||||
border-bottom: 0;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.row-new-property {
|
||||
background-color: #E7E7E7 !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<style type="text/css">
|
||||
|
||||
html { }
|
||||
|
||||
html,body {
|
||||
margin:0;
|
||||
padding:0 px;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
h6 {
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
|
||||
.table-settings {
|
||||
padding-right: 5px;
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 11px;
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
|
||||
.table-settings p {
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
|
||||
.table-settings TR:nth-child(even) {
|
||||
background-color: #FAFAFA
|
||||
}
|
||||
|
||||
.table-settings td {
|
||||
text-align:left;
|
||||
border-width: 5px;
|
||||
padding: 5px !important;
|
||||
border-style: none;
|
||||
border-color: #F2F2F2;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.table-header1 td {
|
||||
background-color: #D0D0D0;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border-width: 5px;
|
||||
padding: 5px;
|
||||
border-style: none;
|
||||
border-color: #F2F2F2;
|
||||
}
|
||||
|
||||
.category-level1 {
|
||||
background-color: #E0E0E0;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.category-level2 {
|
||||
background-color: #E0E0E0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.anchor-style {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.anchor-level2 {
|
||||
|
||||
}
|
||||
|
||||
.table-value {
|
||||
border: 0px;
|
||||
width: 100%;
|
||||
padding: 0px;
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.table-value th {
|
||||
background-color: #F9F9F9;
|
||||
padding: 0px;
|
||||
text-align:left;
|
||||
font-size: 11px;
|
||||
padding-right: 0px;
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
font-weight: normal;
|
||||
border-width: 0px;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
.table-value tr {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.table-value td {
|
||||
border-bottom: 0;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.row-new-property {
|
||||
background-color: #E7E7E7 !important;
|
||||
}
|
||||
|
||||
.description summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
details.description[open] summary::after {
|
||||
content: attr(data-open);
|
||||
}
|
||||
|
||||
details.description:not([open]) summary::after {
|
||||
content: attr(data-close);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,404 @@
|
||||
# Per-object documentation context. Replaces all of the $script:* accumulators
|
||||
# the old Documentation.psm1 reset at the top of every Get-ObjectDocumentation
|
||||
# call. Input providers and *DocHandler classes mutate the lists via Add*()
|
||||
# methods; the engine calls ToResult() to produce the per-object PSCustomObject
|
||||
# the output providers consume.
|
||||
#
|
||||
# Substitution map (from DocumentationMigration.md):
|
||||
# $global:chkIncludeScripts.IsChecked -> Options.IncludeScripts
|
||||
# $global:chkExcludeScriptSignature.IsChecked -> Options.ExcludeScriptSignature
|
||||
# $global:documentationLanguage -> Language
|
||||
# $script:objectBasicInfo -> BasicInfo
|
||||
# $script:objectSettingsData -> SettingsData
|
||||
# $script:objectComplianceActionData -> ComplianceActions
|
||||
# $script:applicabilityRules -> ApplicabilityRules
|
||||
# $script:objectAssignments -> Assignments
|
||||
# $script:objectScripts -> Scripts
|
||||
# $script:customTables -> CustomTables
|
||||
# $script:admxCategories -> ADMXCategories
|
||||
# $script:ObjectTypeFullTable -> ObjectTypeFullTable
|
||||
# $script:scopeTags -> ScopeTags
|
||||
# $script:languageStrings -> LanguageStrings
|
||||
# $script:offlineDocumentation -> SourceTenantUnavailable (formerly OfflineDocumentation)
|
||||
# $script:settingsProperties, CurrentSubCategory, ValueOutputProperty -> per-call
|
||||
# fields used during input-provider walks; added incrementally as providers land
|
||||
|
||||
class DocumentationContext {
|
||||
# ---- Inputs ----
|
||||
[object] $PolicyObject
|
||||
[string] $Language
|
||||
[hashtable]$Options
|
||||
|
||||
# ---- Cross-batch caches (lazily populated; survive ResetForObject so they
|
||||
# amortize across all policies in one bulk run, matching old code's
|
||||
# $global:* caches) ----
|
||||
[hashtable]$LanguageStrings = @{}
|
||||
[object[]] $ScopeTags = @() # /deviceManagement/roleScopeTags
|
||||
[hashtable]$CachedCfgSettings = @{} # SettingsCatalog: settingDefinitionId -> definition
|
||||
[object[]] $CfgCategories = @() # /deviceManagement/configurationCategories + /complianceCategories
|
||||
# Resolved assignment-filter display names (filterId -> displayName, or $null
|
||||
# for IDs the directory didn't return on lookup so we don't keep retrying).
|
||||
# Survives ResetForObject so a bulk-doc run resolves each filter exactly
|
||||
# once across all policies.
|
||||
[hashtable]$FilterNamesById = @{}
|
||||
# True once the tenant-wide assignment-filter list has been loaded into
|
||||
# FilterNamesById (from the login-time dependency cache, the run prefetch
|
||||
# batch, or the lazy fallback in Add-AssignmentsForObject). Acts as a
|
||||
# negative-cache stamp too, so a failed tenant-wide fetch isn't retried
|
||||
# per policy.
|
||||
[bool] $FiltersLoaded = $false
|
||||
# Resolved assignment-group display names (groupId -> displayName, or $null
|
||||
# for IDs the directory didn't return on lookup so we don't keep retrying).
|
||||
# Survives ResetForObject so one bulk-doc run resolves each group at most
|
||||
# once across all policies. Front-loaded in one getByIds batch by the prefetch
|
||||
# (when the "Use Batch API" setting is on); otherwise filled lazily per-object
|
||||
# by Add-AssignmentsForObject. Same negative-cache semantics as FilterNamesById.
|
||||
[hashtable]$GroupNamesById = @{}
|
||||
# Per-run prefetch of policy sub-resources, populated by
|
||||
# Initialize-DocumentationRunPrefetch in one Graph $batch before the output
|
||||
# loop: policyId -> settings[] (with settingDefinitions expanded) for
|
||||
# Settings Catalog / Compliance V2 policies. Cleared at the start of every
|
||||
# run so a re-documented policy reflects current tenant state.
|
||||
[hashtable]$PrefetchedPolicySettings = @{}
|
||||
# When true, the SOURCE tenant the export came from is not reachable, so
|
||||
# source-tenant-specific lookups (assignments->groups, scope-tag/filter/app
|
||||
# names, named locations, ToU, linked certs, reusable settings, per-policy
|
||||
# settings-by-id) are skipped. It does NOT mean "no tenant at all": generic
|
||||
# Intune schema (setting definitions/categories, ADMX, intent templates,
|
||||
# resourceOperations) is identical on every tenant and is still fetched from
|
||||
# whatever tenant is connected, gated by Test-DocumentationGraphAvailable.
|
||||
[bool] $SourceTenantUnavailable
|
||||
|
||||
# Title the document by something other than the policy's display name, and
|
||||
# fold the Basics rows into the settings table instead of emitting a second
|
||||
# table. Both exist for the default enrollment policies: Windows Hello for
|
||||
# Business, Windows Restore, the device limit, the platform restrictions and
|
||||
# the enrollment status page all ship under the SAME display name, "All users
|
||||
# and all devices", so a document headed by that name does not say which
|
||||
# policy it is, and the two tables between them hold only a handful of rows.
|
||||
# Set per object by the ObjectInfo customizer; both reset between objects.
|
||||
[string] $DocumentName = $null
|
||||
[bool] $MergeBasicInfo = $false
|
||||
|
||||
# ---- Per-object accumulators (drained by ToResult) ----
|
||||
[System.Collections.Generic.List[object]] $BasicInfo = [System.Collections.Generic.List[object]]::new()
|
||||
[System.Collections.Generic.List[object]] $SettingsData = [System.Collections.Generic.List[object]]::new()
|
||||
[System.Collections.Generic.List[object]] $ComplianceActions = [System.Collections.Generic.List[object]]::new()
|
||||
[System.Collections.Generic.List[object]] $ApplicabilityRules = [System.Collections.Generic.List[object]]::new()
|
||||
[System.Collections.Generic.List[object]] $Assignments = [System.Collections.Generic.List[object]]::new()
|
||||
[System.Collections.Generic.List[object]] $Scripts = [System.Collections.Generic.List[object]]::new()
|
||||
[System.Collections.Generic.List[object]] $CustomTables = [System.Collections.Generic.List[object]]::new()
|
||||
[object[]] $ADMXCategories = @() # /deviceManagement/groupPolicyCategories?$expand=parent,definitions — batch cache, survives ResetForObject
|
||||
|
||||
# Intent template caches (deviceManagementIntent input provider). Keyed by
|
||||
# templateId / categoryId; survive ResetForObject so a bulk run of N intents
|
||||
# against the same template only pays one round-trip per category.
|
||||
[hashtable] $IntentCategories = @{} # templateId -> categories[] (with $expand=settingDefinitions)
|
||||
[hashtable] $IntentCatRecommendedSettings = @{} # categoryId -> recommendedSettings[]
|
||||
|
||||
# ---- Cross-type accumulator (for ScopeTags consolidated table at end of batch) ----
|
||||
[hashtable] $ObjectTypeFullTable = @{}
|
||||
|
||||
# ---- Result fields populated by input providers / handlers ----
|
||||
[string[]] $DefaultDocumentationProperties = @('Name','Value')
|
||||
[object[]] $DisplayProperties = @()
|
||||
[string] $ErrorText
|
||||
[string] $InputType
|
||||
[bool] $UpdateFilteredObject
|
||||
[object[]] $UnconfiguredProperties = @()
|
||||
|
||||
# ---- Walker / translate-primitive state (replaces $script:currentObject,
|
||||
# $script:CurrentSubCategory, $script:propLevel, $script:propertySeparator,
|
||||
# $script:objectSeparator) ----
|
||||
[object] $CurrentObject
|
||||
[string] $CurrentSubCategory
|
||||
[int] $PropLevel = 0
|
||||
[string] $PropertySeparator = ','
|
||||
[string] $ObjectSeparator = [System.Environment]::NewLine
|
||||
|
||||
DocumentationContext() {
|
||||
$this.Options = [DocumentationContext]::DefaultOptions()
|
||||
$this.Language = 'en'
|
||||
}
|
||||
|
||||
DocumentationContext([object]$PolicyObject, [string]$Language, [hashtable]$Options) {
|
||||
$this.PolicyObject = $PolicyObject
|
||||
$this.Language = if ($Language) { $Language } else { 'en' }
|
||||
$this.Options = if ($Options) { $Options } else { [DocumentationContext]::DefaultOptions() }
|
||||
}
|
||||
|
||||
# Default Options hashtable. Keys recognised by the engine:
|
||||
# IncludeScripts [bool] emit Scripts collection (default true)
|
||||
# ExcludeScriptSignature [bool] strip script signing blocks (default false)
|
||||
# IncludePolicyId [bool] engine appends an 'Id' BasicInfo row after
|
||||
# handler/input dispatch when true (default false)
|
||||
static [hashtable] DefaultOptions() {
|
||||
return @{
|
||||
IncludeScripts = $true
|
||||
ExcludeScriptSignature = $false
|
||||
IncludePolicyId = $false
|
||||
Language = 'en'
|
||||
PropertySeparator = ';'
|
||||
ObjectSeparator = [System.Environment]::NewLine
|
||||
# When true, the engine post-step that translates $obj.assignments
|
||||
# into Assignment rows is skipped. Old engine gated this on
|
||||
# $global:chkExcludeAssignments (default off — assignments included).
|
||||
ExcludeAssignments = $false
|
||||
# ObjectInfo walker: when a property is null on the input, the walker
|
||||
# can either skip emitting a row OR substitute the prop's declared
|
||||
# unconfiguredValue / defaultValue / emptyValueResourceKey. Old engine
|
||||
# gates these on UI checkboxes. Legacy defaults substitute an explicit
|
||||
# unconfigured value but do not substitute a declared default value.
|
||||
SetUnconfiguredValue = $true
|
||||
SetDefaultValue = $false
|
||||
SkipNotConfigured = $false
|
||||
SkipDefaultValues = $false
|
||||
SkipDisabled = $true
|
||||
NotConfiguredText = 'notConfigured'
|
||||
ValueOutputProperty = 'value'
|
||||
SourceTenantUnavailable = $false
|
||||
# When true, output providers skip the document-info header they emit
|
||||
# at the top of a Full document (Organization / Generated by / Generated
|
||||
# date). Generic because every provider writes the same block; read via
|
||||
# Get-DocumentationOption so all providers honour it consistently.
|
||||
SkipDocumentInfo = $false
|
||||
# A policy type that no handler and no input provider claims is
|
||||
# documented by the generic fallback - basic info plus one row per
|
||||
# property - instead of producing a page with nothing on it. On by
|
||||
# default: a rough table beats a blank page, and the alternative
|
||||
# (Enrollment Notifications, Windows Hello for Business and friends
|
||||
# documenting to nothing) reads as a bug to everyone who hits it.
|
||||
# Off means those types are logged and skipped entirely, not emitted
|
||||
# as an empty document. Overridden per run via -Options, and per
|
||||
# user by the Output Settings checkbox, which saves to this key.
|
||||
FallbackDocumentation = $true
|
||||
Outputs = @{}
|
||||
}
|
||||
}
|
||||
|
||||
# Reset per-object accumulators so the same context instance can be reused
|
||||
# across objects in a bulk run (cheaper than constructing a new one).
|
||||
[void] ResetForObject([object]$PolicyObject) {
|
||||
$this.PolicyObject = $PolicyObject
|
||||
$this.CurrentObject = if ($PolicyObject -and $PolicyObject.PSObject.Properties['JsonObject']) { $PolicyObject.JsonObject } else { $PolicyObject }
|
||||
$this.BasicInfo.Clear()
|
||||
$this.SettingsData.Clear()
|
||||
$this.ComplianceActions.Clear()
|
||||
$this.ApplicabilityRules.Clear()
|
||||
$this.Assignments.Clear()
|
||||
$this.Scripts.Clear()
|
||||
$this.CustomTables.Clear()
|
||||
# ADMXCategories deliberately not cleared — batch-scoped cache (matches old $script:admxCategories)
|
||||
$this.DefaultDocumentationProperties = @('Name','Value')
|
||||
$this.DisplayProperties = @()
|
||||
$this.ErrorText = $null
|
||||
$this.InputType = $null
|
||||
$this.UpdateFilteredObject = $false
|
||||
$this.UnconfiguredProperties = @()
|
||||
$this.CurrentSubCategory = $null
|
||||
$this.PropLevel = 0
|
||||
$this.DocumentName = $null
|
||||
$this.MergeBasicInfo = $false
|
||||
}
|
||||
|
||||
# ---- Add* methods (used by input providers / handlers) ----
|
||||
|
||||
[void] AddBasic([string]$Name, [object]$Value) {
|
||||
$this.AddBasic($Name, $Value, $null)
|
||||
}
|
||||
|
||||
# Preferred overload — pass the source field name (e.g. 'displayName',
|
||||
# 'state', 'createdDateTime') as EntityKey. Compare logic and other
|
||||
# downstream tools key rows by EntityKey rather than the localized Name.
|
||||
[void] AddBasic([string]$Name, [object]$Value, [string]$EntityKey) {
|
||||
$this.BasicInfo.Add([PSCustomObject]@{
|
||||
Name = $Name; Value = $Value; EntityKey = $EntityKey
|
||||
})
|
||||
}
|
||||
|
||||
[void] AddProperty([string]$Name, [object]$Value) {
|
||||
$this.SettingsData.Add([PSCustomObject]@{ Name = $Name; Value = $Value })
|
||||
}
|
||||
|
||||
[void] AddProperty([string]$Name, [object]$Value, [string]$Category) {
|
||||
$this.SettingsData.Add([PSCustomObject]@{ Name = $Name; Value = $Value; Category = $Category })
|
||||
}
|
||||
|
||||
[void] AddProperty([string]$Name, [object]$Value, [string]$Category, [string]$SubCategory) {
|
||||
$this.SettingsData.Add([PSCustomObject]@{ Name = $Name; Value = $Value; Category = $Category; SubCategory = $SubCategory })
|
||||
}
|
||||
|
||||
[void] AddSetting([object]$Setting) {
|
||||
# Every settings row must carry a stable, language-independent
|
||||
# EntityKey — it is the join key for documentation-based compare (and
|
||||
# any other downstream tooling). Manifest/Profile/handler rows set it
|
||||
# themselves; Settings Catalog / Intent walker rows carry the identity
|
||||
# as SettingId (+ ParentSettingId / RowIndex), so derive it here:
|
||||
# [<ParentSettingId>/]<SettingId>[#<RowIndex>]
|
||||
# RowIndex > 0 disambiguates repeated group-collection rows (e.g.
|
||||
# firewall rule lists) — positional matching, same as the old project.
|
||||
if ($Setting -and (-not $Setting.PSObject.Properties['EntityKey'] -or
|
||||
[string]::IsNullOrEmpty([string]$Setting.EntityKey))) {
|
||||
$key = $null
|
||||
if ($Setting.PSObject.Properties['SettingId'] -and $Setting.SettingId) {
|
||||
$key = [string]$Setting.SettingId
|
||||
if ($Setting.PSObject.Properties['ParentSettingId'] -and $Setting.ParentSettingId) {
|
||||
$key = "$($Setting.ParentSettingId)/$key"
|
||||
}
|
||||
if ($Setting.PSObject.Properties['RowIndex'] -and [int]$Setting.RowIndex -gt 0) {
|
||||
$key = "$key#$($Setting.RowIndex)"
|
||||
}
|
||||
}
|
||||
if ($key) {
|
||||
$Setting | Add-Member -MemberType NoteProperty -Name 'EntityKey' -Value $key -Force
|
||||
}
|
||||
else {
|
||||
Write-LogDebug "Documentation row without EntityKey: '$($Setting.Name)' ($($this.InputType))"
|
||||
}
|
||||
}
|
||||
$this.SettingsData.Add($Setting)
|
||||
}
|
||||
|
||||
[void] AddComplianceAction([string]$Action, [string]$Schedule, [string]$MessageTemplate, [string]$EmailCC) {
|
||||
$this.ComplianceActions.Add([PSCustomObject]@{
|
||||
Action = $Action
|
||||
Schedule = $Schedule
|
||||
MessageTemplate = $MessageTemplate
|
||||
EmailCC = $EmailCC
|
||||
})
|
||||
}
|
||||
|
||||
[void] AddApplicabilityRule([string]$Rule, [string]$Property, [object]$Value) {
|
||||
$this.ApplicabilityRules.Add([PSCustomObject]@{
|
||||
Rule = $Rule; Property = $Property; Value = $Value
|
||||
})
|
||||
}
|
||||
|
||||
[void] AddAssignment([object]$Assignment) {
|
||||
$this.Assignments.Add($Assignment)
|
||||
}
|
||||
|
||||
[void] AddCustomTable([object]$Table) {
|
||||
$this.CustomTables.Add($Table)
|
||||
}
|
||||
|
||||
# AddScript honors Options.IncludeScripts so output providers don't need to gate
|
||||
# on $global:chkIncludeScripts anymore.
|
||||
[void] AddScript([string]$Header, [string]$Caption, [string]$Content) {
|
||||
if (-not $this.Options.IncludeScripts) { return }
|
||||
if (-not $Content) { return }
|
||||
if ($this.Options.ExcludeScriptSignature) {
|
||||
$Content = [regex]::Replace(
|
||||
$Content,
|
||||
'(?ms)^\s*# SIG # Begin signature block.*?# SIG # End signature block\s*$',
|
||||
''
|
||||
).TrimEnd()
|
||||
}
|
||||
$this.Scripts.Add([PSCustomObject]@{
|
||||
Header = $Header
|
||||
Caption = $Caption
|
||||
ScriptContent = $Content
|
||||
})
|
||||
}
|
||||
|
||||
# Produce the per-object result PSCustomObject that output providers consume.
|
||||
# Output-provider contract: this is the exact set of fields outputs may read.
|
||||
[PSCustomObject] ToResult() {
|
||||
$updateNotConfigured = $true
|
||||
$notConfiguredLoc = Get-LanguageString 'SettingDetails.notConfigured'
|
||||
$notConfiguredText = ''
|
||||
if($this.Options.NotConfiguredText -eq 'notConfigured') {
|
||||
$notConfiguredText = $notConfiguredLoc
|
||||
}
|
||||
elseif($this.Options.NotConfiguredText -eq 'asis') {
|
||||
$updateNotConfigured = $false
|
||||
}
|
||||
|
||||
$settings = @($this.SettingsData | Where-Object {
|
||||
if((-not ($_.PSObject.Properties | Where-Object Name -eq "RawValue")) -or
|
||||
($_.AlwaysAddValue -eq $true))
|
||||
{ return $true }
|
||||
|
||||
if ($this.Options.SkipDisabled -and $_.Enabled -is [bool] -and -not $_.Enabled) { return $false }
|
||||
if ($this.Options.SkipDefaultValues -and
|
||||
$null -ne $_.RawValue -and
|
||||
(($null -ne $_.DefaultValue -and $_.RawValue -eq $_.DefaultValue) -or
|
||||
($null -ne $_.UnconfiguredValue -and $_.RawValue -eq $_.UnconfiguredValue))) { return $false }
|
||||
if ($this.Options.SkipNotConfigured -and
|
||||
(($_.RawValue -isnot [array] -and (
|
||||
$null -eq $_.RawValue -or
|
||||
"$($_.RawValue)" -eq "" -or
|
||||
"$($_.RawValue)" -eq "notConfigured")) -or
|
||||
#($null -ne $_.UnconfiguredValue -and $_.RawValue -eq $_.UnconfiguredValue)) -or
|
||||
($_.RawValue -is [array] -and $_.RawValue.Count -eq 0) -or
|
||||
($this.UnconfiguredProperties | Where-Object EntityKey -eq $_.EntityKey))) { return $false }
|
||||
|
||||
|
||||
if($updateNotConfigured -and (($_.RawValue -isnot [array] -and ($null -eq $_.RawValue -or "$($_.RawValue)" -eq "" -or "$($_.RawValue)" -eq "notConfigured") -and [String]::IsNullOrEmpty($_.Value)) -or ($_.RawValue -is [array] -and ($_.RawValue | Measure-Object).Count -eq 0)))
|
||||
{
|
||||
$_.Value = $notConfiguredText
|
||||
}
|
||||
|
||||
if ($this.Options.SkipNotConfigured -and $_.Value -eq $notConfiguredLoc) {
|
||||
Write-Log "Skipping property $($_.Name) based on '$($notConfiguredLoc)' string value" 2
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
})
|
||||
|
||||
if ($this.Options.NotConfiguredText -eq 'empty') {
|
||||
foreach ($setting in $settings) {
|
||||
if ($setting.Value -eq 'notConfigured') { $setting.Value = '' }
|
||||
}
|
||||
}
|
||||
|
||||
# Basic-info rows carry only a localized display Value (no RawValue), so
|
||||
# apply SkipNotConfigured the same way settings do at their final check
|
||||
# above: drop any row whose value is a "not configured" string. Handlers
|
||||
# emit that value from more than one language key (SettingDetails.notConfigured
|
||||
# for the settings path, Inputs.notConfigured for basic toggles like
|
||||
# connectedAppsEnabled / credentialProviderRoleState), so match against both
|
||||
# so this stays correct if a locale ever diverges the two.
|
||||
$basicRows = @($this.BasicInfo)
|
||||
if ($this.Options.SkipNotConfigured) {
|
||||
$notConfiguredValues = @(
|
||||
$notConfiguredLoc
|
||||
Get-LanguageString 'Inputs.notConfigured'
|
||||
) | Where-Object { $_ } | Select-Object -Unique
|
||||
$basicRows = @($basicRows | Where-Object {
|
||||
if ($_.Value -in $notConfiguredValues) {
|
||||
Write-Log "Skipping basic property $($_.Name) based on '$($_.Value)' string value" 2
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
})
|
||||
}
|
||||
|
||||
# One table instead of two: the basic rows lead, then the settings, in the
|
||||
# order a reader meets them on the portal blade. Done here rather than in
|
||||
# each output provider so every format gets the same shape, and done AFTER
|
||||
# the engine's post-steps so the scope-tag and assignment rows they append
|
||||
# to BasicInfo come along too.
|
||||
if ($this.MergeBasicInfo) {
|
||||
$settings = @($basicRows) + @($settings)
|
||||
$basicRows = @()
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
BasicInfo = $basicRows
|
||||
FilteredSettings = $settings
|
||||
DocumentName = $this.DocumentName
|
||||
ComplianceActions = @($this.ComplianceActions)
|
||||
ApplicabilityRules = @($this.ApplicabilityRules)
|
||||
Assignments = @($this.Assignments)
|
||||
Scripts = @($this.Scripts)
|
||||
CustomTables = @($this.CustomTables)
|
||||
DisplayProperties = $this.DisplayProperties
|
||||
DefaultDocumentationProperties = $this.DefaultDocumentationProperties
|
||||
ErrorText = $this.ErrorText
|
||||
InputType = $this.InputType
|
||||
UpdateFilteredObject = $this.UpdateFilteredObject
|
||||
UnconfiguredProperties = $this.UnconfiguredProperties
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Abstract base for the per-@odata.type custom documentation handlers populated
|
||||
# in phase 4. Subclasses live under Internal/Documentation/PolicyTypeHandlers/, declare
|
||||
# the @odata.type(s) they claim, and override Document() to fill the context.
|
||||
#
|
||||
# Registration is by composition (handler instance registers itself with the
|
||||
# [DocumentationRegistry] at file load time), not by reflection — explicit so
|
||||
# dispatch order is deterministic and missing registrations are obvious.
|
||||
|
||||
class DocumentationHandlerBase {
|
||||
[string[]] $ODataTypes
|
||||
|
||||
DocumentationHandlerBase() {
|
||||
$this.ODataTypes = @()
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
throw "DocumentationHandlerBase.Document is abstract - override in derived class $($this.GetType().Name)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
class DocumentationRegistry {
|
||||
# ---- Output providers (phase 1, populated) ----
|
||||
static [System.Collections.Generic.List[PSCustomObject]] $Outputs = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
# ---- Per-@odata.type custom handler classes (phase 4 populates) ----
|
||||
static [System.Collections.Generic.List[object]] $Handlers = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
# ---- Schema-driven input providers (phase 3 populates) ----
|
||||
# Each provider is a PSCustomObject with: Name, Order (int, lower = higher
|
||||
# priority), Match (scriptblock taking $PolicyObject, $Context), Translate.
|
||||
# FindInputProvider evaluates providers in Order and returns the FIRST whose
|
||||
# Match claims the object; the rest are skipped. The generic fallback registers
|
||||
# with Order = [int]::MaxValue so it is always evaluated last.
|
||||
static [System.Collections.Generic.List[PSCustomObject]] $InputProviders = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
# Monotonic registration counter, used as a stable tiebreak when two providers
|
||||
# share the same Order (preserves registration order for equal-Order providers).
|
||||
static [int] $InputProviderSeq = 0
|
||||
|
||||
# ---- ObjectInfo walker customizations ----
|
||||
# These augment schema-driven Manifest/Profile translation without claiming
|
||||
# the whole object like a DocumentationHandlerBase implementation does.
|
||||
static [System.Collections.Generic.List[PSCustomObject]] $ObjectInfoCustomizers = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
# ---- Outputs ----
|
||||
|
||||
static [void] RegisterOutput([PSCustomObject]$Provider) {
|
||||
if (-not $Provider.Value) { throw "Output provider must have a Value" }
|
||||
$existing = [DocumentationRegistry]::Outputs | Where-Object { $_.Value -eq $Provider.Value }
|
||||
if ($existing) {
|
||||
[DocumentationRegistry]::Outputs.Remove($existing) | Out-Null
|
||||
}
|
||||
[DocumentationRegistry]::Outputs.Add($Provider)
|
||||
}
|
||||
|
||||
static [PSCustomObject] FindOutput([string]$Value) {
|
||||
return [DocumentationRegistry]::Outputs | Where-Object { $_.Value -eq $Value } | Select-Object -First 1
|
||||
}
|
||||
|
||||
# ---- Handlers ----
|
||||
|
||||
static [void] RegisterHandler([object]$Handler) {
|
||||
if (-not $Handler.ODataTypes -or $Handler.ODataTypes.Count -eq 0) {
|
||||
throw "Documentation handler must declare ODataTypes"
|
||||
}
|
||||
# Replace any existing handler claiming the same @odata.type
|
||||
$newKeys = @($Handler.ODataTypes)
|
||||
$stale = [DocumentationRegistry]::Handlers | Where-Object {
|
||||
$existingKeys = @($_.ODataTypes)
|
||||
foreach ($k in $existingKeys) { if ($newKeys -contains $k) { return $true } }
|
||||
$false
|
||||
}
|
||||
foreach ($s in $stale) { [DocumentationRegistry]::Handlers.Remove($s) | Out-Null }
|
||||
[DocumentationRegistry]::Handlers.Add($Handler)
|
||||
}
|
||||
|
||||
static [object] FindHandler([string]$ODataType) {
|
||||
if (-not $ODataType) { return $null }
|
||||
foreach ($h in [DocumentationRegistry]::Handlers) {
|
||||
if ($h.ODataTypes -contains $ODataType) { return $h }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# ---- ObjectInfo walker customizations ----
|
||||
|
||||
static [void] RegisterObjectInfoCustomizer([PSCustomObject]$Customizer) {
|
||||
if (-not $Customizer.Name) { throw "ObjectInfo customizer must have a Name" }
|
||||
# A customizer either claims specific @odata.types (ODataTypes/ODataTypePatterns)
|
||||
# or opts into MatchAll to run for every object (its hooks branch internally,
|
||||
# matching the old DocumentationCustom.psm1 single-provider model).
|
||||
if (-not $Customizer.MatchAll -and
|
||||
(-not $Customizer.ODataTypes -or $Customizer.ODataTypes.Count -eq 0) -and
|
||||
(-not $Customizer.ODataTypePatterns -or $Customizer.ODataTypePatterns.Count -eq 0)) {
|
||||
throw "ObjectInfo customizer $($Customizer.Name) must declare MatchAll, ODataTypes or ODataTypePatterns"
|
||||
}
|
||||
$existing = [DocumentationRegistry]::ObjectInfoCustomizers | Where-Object Name -EQ $Customizer.Name
|
||||
if ($existing) {
|
||||
[DocumentationRegistry]::ObjectInfoCustomizers.Remove($existing) | Out-Null
|
||||
}
|
||||
[DocumentationRegistry]::ObjectInfoCustomizers.Add($Customizer)
|
||||
}
|
||||
|
||||
static [object[]] FindObjectInfoCustomizers([string]$ODataType) {
|
||||
return @([DocumentationRegistry]::ObjectInfoCustomizers | Where-Object {
|
||||
if ($_.MatchAll) { return $true }
|
||||
if (-not $ODataType) { return $false }
|
||||
if ($_.ODataTypes -contains $ODataType) { return $true }
|
||||
foreach ($pattern in @($_.ODataTypePatterns)) {
|
||||
if ($ODataType -like $pattern) { return $true }
|
||||
}
|
||||
return $false
|
||||
})
|
||||
}
|
||||
|
||||
# ---- Input providers ----
|
||||
|
||||
static [void] RegisterInputProvider([PSCustomObject]$Provider) {
|
||||
if (-not $Provider.Name) { throw "Input provider must have a Name" }
|
||||
if (-not $Provider.Match) { throw "Input provider $($Provider.Name) must have a Match scriptblock" }
|
||||
if (-not $Provider.Translate){ throw "Input provider $($Provider.Name) must have a Translate scriptblock" }
|
||||
|
||||
# Default Order for providers that don't declare one: after the specific
|
||||
# providers (which use < 100), before the fallback ([int]::MaxValue).
|
||||
if (-not $Provider.PSObject.Properties['Order'] -or $null -eq $Provider.Order) {
|
||||
$Provider | Add-Member -NotePropertyName Order -NotePropertyValue 100 -Force
|
||||
}
|
||||
# Stable tiebreak for equal Orders (registration order).
|
||||
$Provider | Add-Member -NotePropertyName _Seq -NotePropertyValue ([DocumentationRegistry]::InputProviderSeq++) -Force
|
||||
|
||||
$existing = [DocumentationRegistry]::InputProviders | Where-Object { $_.Name -eq $Provider.Name }
|
||||
if ($existing) {
|
||||
[DocumentationRegistry]::InputProviders.Remove($existing) | Out-Null
|
||||
}
|
||||
[DocumentationRegistry]::InputProviders.Add($Provider)
|
||||
}
|
||||
|
||||
static [PSCustomObject] FindInputProvider([object]$PolicyObject) {
|
||||
return [DocumentationRegistry]::FindInputProvider($PolicyObject, $null)
|
||||
}
|
||||
|
||||
# Evaluate providers in ascending Order (ties broken by registration sequence)
|
||||
# and return the first whose Match claims the object. $Context is passed to
|
||||
# Match as a second argument; providers that declare only param($PolicyObject)
|
||||
# ignore it, while the fallback reads it to honor the opt-in option.
|
||||
static [PSCustomObject] FindInputProvider([object]$PolicyObject, [object]$Context) {
|
||||
$ordered = [DocumentationRegistry]::InputProviders | Sort-Object @{ Expression = { [int]$_.Order } }, @{ Expression = { [int]$_._Seq } }
|
||||
foreach ($p in $ordered) {
|
||||
try {
|
||||
if (& $p.Match $PolicyObject $Context) {
|
||||
Write-Log "InputProvider found: $($p.Name) matched '$($PolicyObject.Name)' - Policy Type: $($PolicyObject.PolicyName)"
|
||||
return $p
|
||||
}
|
||||
} catch {
|
||||
# A throwing Match must not silently skip the provider - that turns a
|
||||
# buggy Match into a mysterious "no provider matched / empty document".
|
||||
# Log it (warning) and keep evaluating the remaining providers.
|
||||
Write-Log "Documentation input provider '$($p.Name)' Match threw for '$($PolicyObject.Name)': $($_.Exception.Message)" 2
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Clear every registry collection. The static List initializers above run
|
||||
# only on the FIRST type load; on Import-Module -Force the type is cached so
|
||||
# they do NOT re-run and the lists keep their previous-import entries. Each
|
||||
# provider/handler re-registers (replace-by-identity) on every import, but
|
||||
# one whose identity was EDITED between reloads (a handler's ODataTypes, a
|
||||
# provider's Name) would orphan its old entry because the replace can't
|
||||
# match the changed key. Resetting at load time guarantees a clean slate.
|
||||
static [void] Reset() {
|
||||
[DocumentationRegistry]::Outputs.Clear()
|
||||
[DocumentationRegistry]::Handlers.Clear()
|
||||
[DocumentationRegistry]::InputProviders.Clear()
|
||||
[DocumentationRegistry]::InputProviderSeq = 0
|
||||
[DocumentationRegistry]::ObjectInfoCustomizers.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
# Get-DocumentationOutputValues backs the -OutputFormat ArgumentCompleter on
|
||||
# Start-GraphBulkDocumentation (invoked via & (Get-Module IntuneManagement) {
|
||||
# Get-DocumentationOutputValues }). ArgumentCompleter is used instead of a
|
||||
# [ValidateSet([IValidateSetValuesGenerator])] so the module still imports on
|
||||
# Windows PowerShell 5.1 (that interface is PS7-only).
|
||||
|
||||
function Get-DocumentationOutputValues {
|
||||
# Runs in the main IntuneManagement module scope so [DocumentationRegistry] is
|
||||
# visible (invoked from the ArgumentCompleter via & (Get-Module ...)).
|
||||
try {
|
||||
return @([DocumentationRegistry]::Outputs | Sort-Object Value | Select-Object -ExpandProperty Value)
|
||||
}
|
||||
catch {
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
# Start each module import with empty collections (see Reset() above). Safe to
|
||||
# run unconditionally: Documentation.ps1 dot-sources Classes/ before Core/,
|
||||
# InputProviders/, OutputProviders/ and PolicyTypeHandlers/, so this executes
|
||||
# before any Add-Documentation*/RegisterHandler call repopulates the lists.
|
||||
[DocumentationRegistry]::Reset()
|
||||
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
@@ -0,0 +1,382 @@
|
||||
# Administrative Templates (ADMX / Group Policy) input provider.
|
||||
#
|
||||
# Ported from old Extensions/Documentation.psm1:916 (Invoke-TranslateADMXObject,
|
||||
# ~190 LOC). Claims @odata.type='#microsoft.graph.groupPolicyConfiguration'
|
||||
# and translates each definitionValue into a documented setting row.
|
||||
#
|
||||
# Definition resolution falls back through three sources, in order:
|
||||
# 1. inline $definitionValue.definition (live $expand=definition export)
|
||||
# 2. #Definition_* flat fields the new project's exporter promotes for
|
||||
# offline use (#Definition_displayName / categoryPath / classType / Id)
|
||||
# 3. live Graph fetch via Invoke-MSGraphAPI
|
||||
# Rows whose displayName can't be resolved (no inline, no embedded, no Graph)
|
||||
# are skipped — matches the golden fixture's offline behavior.
|
||||
#
|
||||
# Presentation values (the configured values for each ADMX setting) translate
|
||||
# differently per presentation type:
|
||||
# DropdownList -> map raw value to item.displayName
|
||||
# ValueList -> name=value pairs joined
|
||||
# MultiText -> values joined
|
||||
# Boolean/Decimal/LongDecimal/Text -> raw value
|
||||
#
|
||||
# Those joined strings stay in Value / ValueWithLabel / RawValue, which Compare,
|
||||
# CSV, Word and JSON all read. The RENDERED table (FullValueTable, used by the
|
||||
# HTML / Markdown / Atlassian outputs) is built separately by
|
||||
# ConvertTo-ADMXValueTable so a list or multi-text setting gets one row per item
|
||||
# instead of one cell holding everything joined, and an explicit-value list gets
|
||||
# its own Key column.
|
||||
#
|
||||
# Settings sorted by CategoryPath at end (matches old code's tail sort).
|
||||
|
||||
function Invoke-InitializeADMXInput {
|
||||
Add-DocumentationInputProvider ([PSCustomObject]@{
|
||||
Name = 'ADMX'
|
||||
Order = 50
|
||||
Match = { param($PolicyObject) $PolicyObject.JsonObject.'@odata.type' -eq '#microsoft.graph.groupPolicyConfiguration' }
|
||||
Translate = { param($PolicyObject, $Context) Invoke-TranslateADMXPolicyObject $PolicyObject $Context }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-TranslateADMXPolicyObject {
|
||||
param($PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
$valueProperty = if ($Context.Options.ValueOutputProperty -eq 'valueWithLabel') { 'ValueWithLabel' } else { 'Value' }
|
||||
$Context.DisplayProperties = @('Name','Status','Value','Category','CategoryPath','RawValue','ValueWithLabel','Created','Modified','Class','DefinitionId')
|
||||
$Context.DefaultDocumentationProperties = @('Name','Status',$valueProperty)
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# --- BasicInfo header ---
|
||||
Add-BasicDefaultValues $PolicyObject -SkipProperties @('')
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'Titles.groupPolicy') '@odata.type'
|
||||
# (Platform supported deliberately omitted — old code at L922 has it commented out;
|
||||
# groupPolicyConfiguration is Windows-only by definition.)
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
# --- Categories cache (batch-scoped, lazy) ---
|
||||
# Generic ADMX category/definition catalog - same on every tenant. Seed from the
|
||||
# session-persistent cache (like CfgCategories); on a miss, fetch from any
|
||||
# connected tenant and warm the cache so later runs in the session skip the GET.
|
||||
if (-not $Context.ADMXCategories -or $Context.ADMXCategories.Count -eq 0) {
|
||||
$Context.ADMXCategories = Get-CacheObject "DocADMXCategories" (@())
|
||||
}
|
||||
if ((-not $Context.ADMXCategories -or $Context.ADMXCategories.Count -eq 0) -and
|
||||
(Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$url = "deviceManagement/groupPolicyCategories?`$expand=parent(`$select=id,displayName,isRoot),definitions(`$select=id,displayName,categoryPath,classType,policyType)&`$select=id,displayName,isRoot"
|
||||
$resp = Invoke-MSGraphAPI -Url $url -ODataMetadata 'skip' -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
if ($resp.Value) {
|
||||
$Context.ADMXCategories = @($resp.Value)
|
||||
Set-CacheObject "DocADMXCategories" $Context.ADMXCategories -Persistent
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError 'Failed to load ADMX group policy categories' $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
# --- definitionValues ---
|
||||
$definitionValues = @()
|
||||
if ($obj.definitionValues) {
|
||||
$definitionValues = @($obj.definitionValues)
|
||||
}
|
||||
elseif (-not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
# Source-tenant-specific: THIS policy's definitionValues by id (404s elsewhere).
|
||||
try {
|
||||
$url = "deviceManagement/groupPolicyConfigurations('$($obj.Id)')/definitionValues?`$expand=definition(`$select=id,classType,displayName,policyType,groupPolicyCategoryId)"
|
||||
$resp = Invoke-MSGraphAPI -Url $url -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
$definitionValues = @($resp.Value)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to load definitionValues for ADMX policy $($obj.Id)" $_.Exception
|
||||
}
|
||||
}
|
||||
if ($definitionValues.Count -eq 0) { return }
|
||||
|
||||
$enabledStr = Get-LanguageString 'Inputs.enabled'
|
||||
$disabledStr = Get-LanguageString 'Inputs.disabled'
|
||||
$propertyStr = Get-LanguageString 'ApplicabilityRules.GridLabel.property'
|
||||
$valueStr = Get-LanguageString 'ApplicabilityRules.GridLabel.value'
|
||||
$keyStr = Get-LanguageString 'SettingDetails.keyColumn'
|
||||
|
||||
## ToDo: Preload the presentation Definitions for all definitionValues with presentationValues defined in one batch
|
||||
# e.g. $definitionValues | Where presentationValues -ne $null -> Add to batch and fetch all in one call.
|
||||
|
||||
$rows = @()
|
||||
foreach ($defValue in $definitionValues) {
|
||||
$definition = Resolve-ADMXDefinition $defValue $Context
|
||||
if (-not $definition -or -not $definition.displayName) {
|
||||
# Unresolvable in current mode — skip (matches golden's offline behavior)
|
||||
continue
|
||||
}
|
||||
|
||||
# Category path: prefer the definition's own field; fall back to the cached
|
||||
# categories lookup when only an id is available.
|
||||
$categoryPath = $definition.categoryPath
|
||||
if (-not $categoryPath -and $Context.ADMXCategories.Count -gt 0) {
|
||||
$matched = $Context.ADMXCategories.definitions | Where-Object { $_.id -eq $definition.id } | Select-Object -First 1
|
||||
if ($matched) { $categoryPath = $matched.categoryPath }
|
||||
}
|
||||
|
||||
# Presentation values — only present when the policy carries configured values
|
||||
$presentationValues = Resolve-ADMXPresentationValues $defValue $obj $Context
|
||||
|
||||
$values = @()
|
||||
$valuesWithLabel = @()
|
||||
$rawValues = @()
|
||||
# One entry per presentation - its label plus the rows it contributes to
|
||||
# the rendered table. Multi-valued presentations contribute one row per
|
||||
# item. The flat $values / $valuesWithLabel / $rawValues below are
|
||||
# deliberately built exactly as before: Compare joins on them.
|
||||
$presEntries = @()
|
||||
# Presentation values present: map each to its label + value (per type)
|
||||
foreach ($pv in $presentationValues) {
|
||||
# Generic presentation metadata (label/dropdown items) resolved from any connected tenant.
|
||||
if (-not $pv.presentation -and $pv.'presentation@odata.bind' -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$pres = Invoke-MSGraphAPI -Url $pv.'presentation@odata.bind' -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
if ($pres) { $pv | Add-Member -MemberType NoteProperty -Name 'presentation' -Value $pres -Force }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
$rawValue = $pv.value
|
||||
$label = $pv.presentation.label
|
||||
$value = $null
|
||||
$valueRows = @()
|
||||
|
||||
switch ($pv.presentation.'@odata.type') {
|
||||
'#microsoft.graph.groupPolicyPresentationDropdownList' {
|
||||
$value = ($pv.presentation.items | Where-Object value -EQ $rawValue).displayName
|
||||
$valueRows = @([PSCustomObject]@{ Key = $null; Value = $value })
|
||||
}
|
||||
default {
|
||||
switch ($pv.'@odata.type') {
|
||||
'#microsoft.graph.groupPolicyPresentationValueList' {
|
||||
$arr = @()
|
||||
foreach ($v in $pv.values) {
|
||||
$arr += "$($v.name)$($Context.PropertySeparator)$($v.value)"
|
||||
$valueRows += [PSCustomObject]@{ Key = $v.name; Value = $v.value }
|
||||
}
|
||||
$value = $arr -join $Context.ObjectSeparator
|
||||
# A plain <list> (no explicitValue) stores each item in
|
||||
# 'name' and leaves 'value' empty. Those are single-column
|
||||
# items, not key/value pairs - fold name into the value.
|
||||
if (@($valueRows | Where-Object { "$($_.Value)" -ne '' }).Count -eq 0) {
|
||||
$valueRows = @($valueRows | ForEach-Object { [PSCustomObject]@{ Key = $null; Value = $_.Key } })
|
||||
}
|
||||
}
|
||||
'#microsoft.graph.groupPolicyPresentationValueMultiText' {
|
||||
$value = $pv.values -join $Context.ObjectSeparator
|
||||
$valueRows = @(foreach ($v in $pv.values) { [PSCustomObject]@{ Key = $null; Value = $v } })
|
||||
}
|
||||
default {
|
||||
# Boolean / Decimal / LongDecimal / Text — value is the raw scalar
|
||||
$value = $rawValue
|
||||
$valueRows = @([PSCustomObject]@{ Key = $null; Value = $rawValue })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$presEntries += [PSCustomObject]@{ Label = $label; Rows = @($valueRows) }
|
||||
$valuesWithLabel += "$label $value"
|
||||
$values += $value
|
||||
$rawValues += $rawValue
|
||||
}
|
||||
|
||||
$tableValue = ConvertTo-ADMXValueTable -Entries $presEntries `
|
||||
-PropertyHeader $propertyStr -KeyHeader $keyStr -ValueHeader $valueStr
|
||||
|
||||
$status = if ($defValue.enabled -eq $true) { $enabledStr } else { $disabledStr }
|
||||
|
||||
$combinedValue = $status
|
||||
if ($values) {
|
||||
$combinedValue += $Context.ObjectSeparator + ($values -join $Context.ObjectSeparator)
|
||||
}
|
||||
|
||||
$combinedValueWithLabel = $status
|
||||
if ($valuesWithLabel) {
|
||||
$combinedValueWithLabel += $Context.ObjectSeparator + ($valuesWithLabel -join $Context.ObjectSeparator)
|
||||
}
|
||||
|
||||
$rows += [PSCustomObject]@{
|
||||
Name = $definition.displayName
|
||||
Description = $definition.explainText
|
||||
Status = $status
|
||||
Value = $values -join $Context.ObjectSeparator
|
||||
CombinedValue = $combinedValue
|
||||
ValueWithLabel = $valuesWithLabel -join $Context.ObjectSeparator
|
||||
FullValueTable = $tableValue
|
||||
CombinedValueWithLabel = $combinedValueWithLabel
|
||||
RawValue = $rawValues -join $Context.PropertySeparator
|
||||
Class = $definition.classType
|
||||
DefinitionId = $definition.id
|
||||
Created = $defValue.createdDateTime
|
||||
Modified = $defValue.lastModifiedDateTime
|
||||
Category = $categoryPath
|
||||
CategoryPath = $categoryPath
|
||||
EntityKey = $definition.id # Required for Compare
|
||||
AlwaysAddValue = if($null -ne $defValue.enabled) { $true } else { $false } # Always include Value if defined
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($row in ($rows | Sort-Object -Property CategoryPath, Name)) {
|
||||
$Context.AddSetting($row)
|
||||
}
|
||||
}
|
||||
|
||||
# Builds the rendered value table for one ADMX setting.
|
||||
#
|
||||
# The column shape is decided once for the whole setting, because the HTML,
|
||||
# Markdown and Atlassian renderers read their headers from the FIRST row and then
|
||||
# fetch every later row by those same property names - mixing shapes inside one
|
||||
# setting would silently blank cells. So: two columns (Property | Value) unless
|
||||
# some presentation contributed real key/value pairs, in which case three
|
||||
# (Property | Key | Value) for every row of that setting.
|
||||
#
|
||||
# The property label is written on the first row of each presentation only, so a
|
||||
# multi-item list reads as one labelled block instead of repeating the label.
|
||||
#
|
||||
# $Entries is one object per presentation: { Label; Rows = @({ Key; Value }) }.
|
||||
function ConvertTo-ADMXValueTable {
|
||||
param(
|
||||
$Entries,
|
||||
[string]$PropertyHeader,
|
||||
[string]$KeyHeader,
|
||||
[string]$ValueHeader
|
||||
)
|
||||
|
||||
$entryArr = @($Entries)
|
||||
if ($entryArr.Count -eq 0) { return @() }
|
||||
|
||||
$hasKeys = $false
|
||||
foreach ($entry in $entryArr) {
|
||||
foreach ($row in @($entry.Rows)) {
|
||||
if ("$($row.Key)" -ne '') { $hasKeys = $true; break }
|
||||
}
|
||||
if ($hasKeys) { break }
|
||||
}
|
||||
|
||||
# A translation that collides with another header would throw on the ordered
|
||||
# hashtable below, so fall back to the untranslated column name.
|
||||
if ($hasKeys -and ($KeyHeader -eq $PropertyHeader -or $KeyHeader -eq $ValueHeader -or -not $KeyHeader)) {
|
||||
$KeyHeader = 'Key'
|
||||
}
|
||||
|
||||
$table = @()
|
||||
foreach ($entry in $entryArr) {
|
||||
$rows = @($entry.Rows)
|
||||
# A presentation with nothing configured still shows its label.
|
||||
if ($rows.Count -eq 0) { $rows = @([PSCustomObject]@{ Key = $null; Value = $null }) }
|
||||
|
||||
$first = $true
|
||||
foreach ($row in $rows) {
|
||||
$out = [ordered]@{}
|
||||
$out[$PropertyHeader] = if ($first) { $entry.Label } else { '' }
|
||||
if ($hasKeys) { $out[$KeyHeader] = $row.Key }
|
||||
$out[$ValueHeader] = $row.Value
|
||||
$table += [PSCustomObject]$out
|
||||
$first = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Comma so a single-row table doesn't unroll to a bare object on return.
|
||||
return ,$table
|
||||
}
|
||||
|
||||
# Three-tier definition resolution: inline -> embedded #Definition_* flat
|
||||
# fields -> live Graph fetch. Returns the synthesized/fetched definition or
|
||||
# $null if nothing resolved.
|
||||
function Resolve-ADMXDefinition {
|
||||
param($DefinitionValue, [DocumentationContext]$Context)
|
||||
|
||||
# 1. Inline (live $expand=definition export already populated it)
|
||||
if ($DefinitionValue.definition -and $DefinitionValue.definition.displayName) {
|
||||
return $DefinitionValue.definition
|
||||
}
|
||||
|
||||
# 2. Embedded #Definition_* flat fields (new project's exporter prefix)
|
||||
$embeddedDisplayName = $DefinitionValue.'#Definition_displayName'
|
||||
if ($embeddedDisplayName) {
|
||||
$syn = [PSCustomObject]@{
|
||||
id = $DefinitionValue.'#Definition_Id'
|
||||
displayName = $embeddedDisplayName
|
||||
classType = $DefinitionValue.'#Definition_classType'
|
||||
categoryPath = $DefinitionValue.'#Definition_categoryPath'
|
||||
explainText = $null
|
||||
policyType = $null
|
||||
}
|
||||
# Attach for future calls
|
||||
$DefinitionValue | Add-Member -MemberType NoteProperty -Name 'definition' -Value $syn -Force
|
||||
return $syn
|
||||
}
|
||||
|
||||
# 3. Live Graph fetch via definition@odata.bind URL. The definition is GENERIC
|
||||
# schema (groupPolicyDefinitions) - same on every tenant - so gated only on
|
||||
# connectivity, not on SourceTenantUnavailable.
|
||||
if ($DefinitionValue.'definition@odata.bind' -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$url = $DefinitionValue.'definition@odata.bind'
|
||||
$def = Invoke-MSGraphAPI -Url $url -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
if ($def) {
|
||||
$DefinitionValue | Add-Member -MemberType NoteProperty -Name 'definition' -Value $def -Force
|
||||
return $def
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to fetch ADMX definition from $($DefinitionValue.'definition@odata.bind')" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# Resolves presentation values + their presentation metadata. Returns array,
|
||||
# possibly empty for definitionValues with no configured presentationValues
|
||||
# (i.e. ADMX settings that are simply Enabled/Disabled with no inputs).
|
||||
function Resolve-ADMXPresentationValues {
|
||||
param($DefinitionValue, $PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
# Already inline? Order them by the canonical presentation order if we can.
|
||||
if ($DefinitionValue.presentationValues -and $DefinitionValue.presentationValues.Count -gt 0) {
|
||||
# The canonical presentation list is generic schema; reorder only needs a
|
||||
# connected tenant. Without one, keep the inline order.
|
||||
if (-not (Test-DocumentationGraphAvailable)) {
|
||||
return @($DefinitionValue.presentationValues)
|
||||
}
|
||||
# Live: pull the canonical presentation list so we can reorder
|
||||
try {
|
||||
$url = "$($DefinitionValue.'definition@odata.bind')/presentations"
|
||||
$resp = Invoke-MSGraphAPI -Url $url -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
$canon = @($resp.Value)
|
||||
if ($canon.Count -gt 0) {
|
||||
$ordered = @()
|
||||
foreach ($p in $canon) {
|
||||
$match = $DefinitionValue.presentationValues | Where-Object 'presentation@odata.bind' -Like "*$($p.Id)*" | Select-Object -First 1
|
||||
if ($match) { $ordered += $match } else { $ordered = @(); break }
|
||||
}
|
||||
if ($ordered.Count -gt 0) { return $ordered }
|
||||
}
|
||||
} catch { }
|
||||
return @($DefinitionValue.presentationValues)
|
||||
}
|
||||
|
||||
# Live fetch (when fixture exported without presentationValues inline). These are
|
||||
# the policy's CONFIGURED values, fetched by policy id - source-tenant-specific
|
||||
# (404s elsewhere) - so gated on -not SourceTenantUnavailable.
|
||||
if ($DefinitionValue.id -and -not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
# Should never get here - $DefinitionValue.id will be empty.
|
||||
$url = "/deviceManagement/groupPolicyConfigurations/$($PolicyObject.id)/definitionValues/$($DefinitionValue.id)/presentationValues?`$expand=presentation"
|
||||
$resp = Invoke-MSGraphAPI -Url $url -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
return @($resp.Value)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to fetch ADMX presentationValues for $($DefinitionValue.id)" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
return @()
|
||||
}
|
||||
|
||||
Invoke-InitializeADMXInput
|
||||
@@ -0,0 +1,154 @@
|
||||
# Compliance V2 input provider — schema-driven compliance policies on the
|
||||
# /deviceManagement/compliancePolicies endpoint.
|
||||
#
|
||||
# Ported from old Extensions/Documentation.psm1:1444 (Invoke-TranslateComplianceV2-
|
||||
# Object). Claims @odata.type='#microsoft.graph.deviceManagementCompliancePolicy'.
|
||||
#
|
||||
# Mirrors DocumentationInputSettingsCatalog.ps1 — same recursive setting walker
|
||||
# (Add-SettingsSetting), same batch caches on the [DocumentationContext]
|
||||
# ($ctx.CfgCategories, $ctx.CachedCfgSettings), same Category/SubCategory
|
||||
# grouping at the end. Differences:
|
||||
# - Settings endpoint: /deviceManagement/compliancePolicies/<id>/settings
|
||||
# - Categories endpoint: /deviceManagement/complianceCategories with the
|
||||
# linux/linuxMdm template filter (matches old code at L1471)
|
||||
# - platformSupported row uses $obj.platforms directly (compliance policies
|
||||
# are single-platform, no templateReference indirection)
|
||||
|
||||
function Invoke-InitializeComplianceV2Input {
|
||||
Add-DocumentationInputProvider ([PSCustomObject]@{
|
||||
Name = 'ComplianceV2'
|
||||
Order = 30
|
||||
Match = { param($PolicyObject) $PolicyObject.JsonObject.'@odata.type' -eq '#microsoft.graph.deviceManagementCompliancePolicy' }
|
||||
Translate = { param($PolicyObject, $Context) Invoke-TranslateComplianceV2Object $PolicyObject $Context }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-TranslateComplianceV2Object {
|
||||
param($PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# --- BasicInfo header rows ---
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'ConfigurationTypes.settingsCatalog') '@odata.type'
|
||||
|
||||
if ($obj.platforms) {
|
||||
$platformType = Get-LanguageString "Platform.$($obj.platforms)"
|
||||
if ($platformType) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.platformSupported') $platformType 'platforms'
|
||||
}
|
||||
}
|
||||
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
# --- Settings ---
|
||||
# Prefer in-policy settings WITH inline settingDefinitions. Inline settings
|
||||
# WITHOUT definitions (the hydrate body only does ?$expand=Settings) would
|
||||
# send the walker into a per-setting /configurationSettings/{id} N+1 — the
|
||||
# same trap the Settings Catalog provider fixed; enrich instead.
|
||||
$cfgSettings = @()
|
||||
if ($obj.Settings -and ($obj.Settings | Measure-Object).Count -gt 0) {
|
||||
$cfgSettings = @($obj.Settings)
|
||||
}
|
||||
|
||||
$hasDefs = $false
|
||||
foreach ($s in $cfgSettings) {
|
||||
if ($s.settingDefinitions -and ($s.settingDefinitions | Measure-Object).Count -gt 0) {
|
||||
$hasDefs = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Bulk runs: Initialize-DocumentationRunPrefetch already fetched these in
|
||||
# one Graph $batch — consume from the per-run cache (authoritative for this
|
||||
# run, even when empty, so an empty-settings policy doesn't trigger a
|
||||
# redundant live GET). The live GET below is the lazy fallback for the
|
||||
# single-policy Get-GraphDocumentation path.
|
||||
if (-not $hasDefs -and $Context.PrefetchedPolicySettings.ContainsKey([string]$obj.Id)) {
|
||||
$cfgSettings = @($Context.PrefetchedPolicySettings[[string]$obj.Id])
|
||||
$hasDefs = $true
|
||||
}
|
||||
|
||||
# Source-tenant-specific: fetches THIS policy's settings by id (404s elsewhere).
|
||||
# Stays gated on -not SourceTenantUnavailable; the walker's generic per-setting
|
||||
# configurationSettings/{id} fallback resolves schema when source is gone.
|
||||
if (-not $hasDefs -and -not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$headers = @{}
|
||||
if ($Context.Language -and $Context.Language -ne 'en') {
|
||||
$headers['Accept-Language'] = $Context.Language
|
||||
}
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/compliancePolicies('$($obj.Id)')/settings?`$expand=settingDefinitions&`$top=1000" -AdditionalHeaders $headers
|
||||
if ($resp -and $resp.Value) {
|
||||
$cfgSettings = @($resp.Value)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to fetch settings for compliance policy $($obj.Id)" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
if ($cfgSettings.Count -eq 0) {
|
||||
Write-Log "ComplianceV2: no settings to document for $($obj.name)" 2
|
||||
return
|
||||
}
|
||||
|
||||
# --- Generic schema caches (session-persistent, shared by reference with the
|
||||
# Settings Catalog provider so the walker's per-setting definition fetches
|
||||
# warm one shared cache). ---
|
||||
$Context.CachedCfgSettings = Get-CacheObject "DocCfgSettingDefinitions" $Context.CachedCfgSettings
|
||||
Set-CacheObject "DocCfgSettingDefinitions" $Context.CachedCfgSettings -Persistent
|
||||
|
||||
# --- Categories (batch-cached). Old code unions linux/linuxMdm template
|
||||
# categories into the same $global:cfgCategories the Settings Catalog uses;
|
||||
# we mirror that by appending to $ctx.CfgCategories rather than replacing.
|
||||
|
||||
|
||||
$Context.CfgCategories = Get-CacheObject "CfgCategories" (@())
|
||||
|
||||
# Generic schema (complianceCategories) - same on every tenant - gated only on connectivity.
|
||||
if (-not ($Context.CfgCategories | Where-Object { $_.settingUsage -eq 'compliance' }) -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/complianceCategories" -ODataMetadata 'minimal' -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
#$resp = Invoke-MSGraphAPI -Url "/deviceManagement/complianceCategories?`$templateCategory=True&`$filter=platforms has 'linux' and technologies has 'linuxMdm'"
|
||||
$Context.CfgCategories += @($resp.Value)
|
||||
Set-CacheObject "CfgCategories" $Context.CfgCategories -Persistent
|
||||
}
|
||||
catch {
|
||||
Write-LogError 'Failed to fetch compliance categories' $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
# --- Seed definition cache from inline settingDefinitions ---
|
||||
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 via the shared SettingsCatalog walker ---
|
||||
Reset-SettingsCatalogPolicyBuffer
|
||||
foreach ($cfgSetting in $cfgSettings) {
|
||||
Add-SettingsSetting $cfgSetting.settingInstance $cfgSetting.settingDefinitions | Out-Null
|
||||
}
|
||||
|
||||
# --- Drain buffer into SettingsData grouped by (Category, SubCategory) ---
|
||||
$buffer = Get-SettingsCatalogPolicyBuffer
|
||||
$unique = $buffer |
|
||||
Select-Object @{ l='CategoryID'; e={ $_.CategoryDefinition.Id } },
|
||||
@{ l='SubCategoryID'; e={ $_.SubCategoryDefinition.Id } } -Unique
|
||||
|
||||
foreach ($pair in $unique) {
|
||||
$rows = $buffer | Where-Object {
|
||||
$_.CategoryDefinition.Id -eq $pair.CategoryID -and
|
||||
$_.SubCategoryDefinition.Id -eq $pair.SubCategoryID
|
||||
}
|
||||
foreach ($row in $rows) {
|
||||
if ($row.Show -eq $false) { continue }
|
||||
$Context.AddSetting($row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeComplianceV2Input
|
||||
@@ -0,0 +1,515 @@
|
||||
# Intent input provider — deviceManagementIntent (Endpoint Security baselines
|
||||
# and templates).
|
||||
#
|
||||
# Ported from old Extensions/Documentation.psm1:1580 (Invoke-TranslateIntent-
|
||||
# Object + helpers). Claims @odata.type='#microsoft.graph.deviceManagementIntent'.
|
||||
#
|
||||
# Intent settings live under /deviceManagement/templates/{templateId}/categories
|
||||
# (with $expand=settingDefinitions) and the per-intent values come from
|
||||
# /deviceManagement/intents/{intentId}/categories/{catId}/settings. Each setting
|
||||
# may be Simple / Collection / Complex / AbstractComplex with recursive children
|
||||
# and dependency constraints that hide settings whose parents aren't configured.
|
||||
#
|
||||
# Live Graph dependencies (resolved via Invoke-MSGraphAPI):
|
||||
# /deviceManagement/templates/{tid}/categories?$expand=settingDefinitions
|
||||
# /deviceManagement/intents/{iid}/categories/{cid}/settings?$expand=...
|
||||
# /deviceManagement/templates/{tid}/categories/{cid}/RecommendedSettings
|
||||
#
|
||||
# Batch-cached on the [DocumentationContext] ($ctx.IntentCategories,
|
||||
# $ctx.IntentCatRecommendedSettings) so a bulk run of N intents against the
|
||||
# same template only pays the round-trips once.
|
||||
|
||||
function Invoke-InitializeIntentInput {
|
||||
Add-DocumentationInputProvider ([PSCustomObject]@{
|
||||
Name = 'Intent'
|
||||
Order = 40
|
||||
Match = { param($PolicyObject) $PolicyObject.JsonObject.'@odata.type' -eq '#microsoft.graph.deviceManagementIntent' }
|
||||
Translate = { param($PolicyObject, $Context) Invoke-TranslateIntentObject $PolicyObject $Context }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-TranslateIntentObject {
|
||||
param($PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
$Context.DefaultDocumentationProperties = @('Name','Value','RecommendedValue')
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
|
||||
$baseLineTemplates = Get-CacheObject "BaseLineTemplates"
|
||||
if(-not $baseLineTemplates)
|
||||
{
|
||||
$baseLineTemplates = (Invoke-MSGraphAPI -Url "/deviceManagement/templates").Value
|
||||
Set-CacheObject "BaseLineTemplates" $baseLineTemplates -Persistent
|
||||
}
|
||||
|
||||
$baseLineTemplate = $baseLineTemplates | Where-Object Id -eq $obj.templateId
|
||||
if(-not $baseLineTemplate)
|
||||
{
|
||||
Write-Log "Could not find Baseline Template with Id $($obj.templateId)" 3
|
||||
}
|
||||
else {
|
||||
$platformType = Get-LanguageString "Platform.$($baseLineTemplate.platformType)"
|
||||
|
||||
if($platformType) { Add-BasicPropertyValue (Get-LanguageString "SettingDetails.platformSupported") $platformType 'platformSupported'}
|
||||
|
||||
if ($baseLineTemplate.templateSubtype -eq "none")
|
||||
{
|
||||
$templateCategoory = $baseLineTemplate.templateType
|
||||
} else {
|
||||
$templateCategoory = $baseLineTemplate.templateSubtype
|
||||
}
|
||||
Add-BasicPropertyValue (Get-LanguageString "TableHeaders.Category") (Get-IntentCategoryFromTemplateType $templateCategoory) "basicCategory"
|
||||
Add-BasicPropertyValue (Get-LanguageString "TableHeaders.policyType") $baseLineTemplate.displayName "basicPolicyType"
|
||||
}
|
||||
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
if (-not $obj.templateId) {
|
||||
Write-Log "Intent: no templateId on '$($obj.displayName)' - cannot translate settings" 2
|
||||
return
|
||||
}
|
||||
|
||||
# Built-in ES template schema is generic. Seed/share the session-persistent
|
||||
# caches by reference so per-templateId/per-category writes below warm the
|
||||
# cache automatically and survive across runs (and tenant switches).
|
||||
$Context.IntentCategories = Get-CacheObject "DocIntentCategories" $Context.IntentCategories
|
||||
Set-CacheObject "DocIntentCategories" $Context.IntentCategories -Persistent
|
||||
$Context.IntentCatRecommendedSettings = Get-CacheObject "DocIntentRecommendedSettings" $Context.IntentCatRecommendedSettings
|
||||
Set-CacheObject "DocIntentRecommendedSettings" $Context.IntentCatRecommendedSettings -Persistent
|
||||
|
||||
# --- Template categories (batch-cached per templateId) ---
|
||||
$categories = $Context.IntentCategories[$obj.templateId]
|
||||
if (-not $categories) {
|
||||
# Built-in Endpoint Security template schema (by templateId) is generic -
|
||||
# same on every tenant - so resolved from any connected tenant, even when
|
||||
# the source tenant of the export is gone.
|
||||
if (-not (Test-DocumentationGraphAvailable)) {
|
||||
Write-Log "Intent: no tenant connected and no cached template categories for $($obj.templateId) - settings will not render" 2
|
||||
return
|
||||
}
|
||||
try {
|
||||
$headers = @{}
|
||||
if ($Context.Language -and $Context.Language -ne 'en') { $headers['Accept-Language'] = $Context.Language }
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/templates/$($obj.templateId)/categories?`$expand=settingDefinitions" -AdditionalHeaders $headers
|
||||
$categories = @($resp.Value)
|
||||
$Context.IntentCategories[$obj.templateId] = $categories
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Intent: failed to fetch template categories for $($obj.templateId)" $_.Exception
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
# Per-object setting buffer (drained at the end into Context.SettingsData
|
||||
# in dependency-respecting order via Add-IntentSettingObjectToList).
|
||||
$script:_intentObjectSettings = [System.Collections.Generic.List[object]]::new()
|
||||
$script:_intentEmittedIds = @{}
|
||||
|
||||
foreach ($category in ($categories | Sort-Object -Property displayName)) {
|
||||
# Per-intent settings for this category (skipped when the input is an
|
||||
# offline file with .settings inlined).
|
||||
$settings = $null
|
||||
if ($obj.'@ObjectFromFile' -eq $true) {
|
||||
$settings = $obj.settings
|
||||
}
|
||||
elseif (-not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
# Source-tenant-specific: this intent's configured values by id (404s elsewhere).
|
||||
# Export path is the @ObjectFromFile branch above.
|
||||
try {
|
||||
$headers = @{}
|
||||
if ($Context.Language -and $Context.Language -ne 'en') { $headers['Accept-Language'] = $Context.Language }
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/intents/$($obj.Id)/categories/$($category.Id)/settings?`$expand=Microsoft.Graph.DeviceManagementComplexSettingInstance/Value" -AdditionalHeaders $headers
|
||||
$settings = $resp.Value
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Intent: failed to fetch settings for intent=$($obj.Id) category=$($category.Id)" $_.Exception
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (-not $settings) { continue }
|
||||
|
||||
# Recommended settings (template-level, also batch-cached per categoryId)
|
||||
if (-not $Context.IntentCatRecommendedSettings.ContainsKey($category.Id)) {
|
||||
# Template-level recommended settings (by templateId) are generic schema.
|
||||
if (Test-DocumentationGraphAvailable) {
|
||||
try {
|
||||
$headers = @{}
|
||||
if ($Context.Language -and $Context.Language -ne 'en') { $headers['Accept-Language'] = $Context.Language }
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/templates/$($obj.templateId)/categories/$($category.Id)/RecommendedSettings" -AdditionalHeaders $headers
|
||||
$Context.IntentCatRecommendedSettings[$category.Id] = @($resp.Value)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Intent: failed to fetch recommended settings for template=$($obj.templateId) category=$($category.Id)" $_.Exception
|
||||
$Context.IntentCatRecommendedSettings[$category.Id] = @()
|
||||
}
|
||||
}
|
||||
else {
|
||||
$Context.IntentCatRecommendedSettings[$category.Id] = @()
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($settingObj in $settings) {
|
||||
Get-IntentSettingInfo $settingObj $category $settingObj.definitionId $settings $Context | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Drain top-level settings (those with no parent and no dependencies).
|
||||
# Children/dependents get visited recursively by Add-IntentSettingObjectToList.
|
||||
$tops = $script:_intentObjectSettings | Where-Object {
|
||||
$null -eq $_.ParentId -and (($_.Dependencies | Measure-Object).Count -eq 0)
|
||||
}
|
||||
foreach ($s in $tops) {
|
||||
Add-IntentSettingObjectToList $s $Context
|
||||
}
|
||||
}
|
||||
|
||||
# Ordered emit: respects dependency constraints (parents resolve to permitted
|
||||
# values before dependent children are added) and recurses to children of any
|
||||
# emitted setting.
|
||||
function Add-IntentSettingObjectToList {
|
||||
param($objSetting, [DocumentationContext]$Context)
|
||||
|
||||
if ($script:_intentEmittedIds.ContainsKey([string]$objSetting.Id)) { return }
|
||||
|
||||
$passConstraint = $true
|
||||
$hasConstraint = $false
|
||||
foreach ($dependencyObj in $objSetting.SettingDefinition.dependencies) {
|
||||
$dependencyItemObj = $script:_intentObjectSettings | Where-Object { $_.SettingDefinition.Id -eq $dependencyObj.definitionId } | Select-Object -First 1
|
||||
if ($dependencyObj.constraints.Count -gt 0) {
|
||||
$hasConstraint = $true
|
||||
foreach ($constraint in $dependencyObj.constraints) {
|
||||
switch ($constraint.'@odata.type') {
|
||||
'#microsoft.graph.deviceManagementSettingBooleanConstraint' {
|
||||
if (($null -eq $dependencyItemObj.RawValue -and $constraint.value -eq $false) -or
|
||||
($dependencyItemObj.RawValue -and "$($dependencyItemObj.RawValue)" -ne "$($constraint.value)")) {
|
||||
$passConstraint = $false
|
||||
}
|
||||
}
|
||||
'#microsoft.graph.deviceManagementEnumConstraint' {
|
||||
if (-not ($constraint.values | Where-Object Value -EQ $dependencyItemObj.RawValue)) {
|
||||
$passConstraint = $false
|
||||
}
|
||||
}
|
||||
'#microsoft.graph.deviceManagementSettingIntegerConstraint' {
|
||||
# Old code inverts the comparison — passes when value is OUT of range.
|
||||
# Preserving the (buggy?) behavior for golden parity.
|
||||
if ($dependencyItemObj.RawValue -ge $constraint.minimumValue -and
|
||||
$dependencyItemObj.RawValue -le $constraint.maximumValue) {
|
||||
$passConstraint = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $passConstraint) { break }
|
||||
}
|
||||
}
|
||||
else {
|
||||
# No explicit constraint — dependency just has to be "set"
|
||||
$passConstraint = ($null -ne $dependencyItemObj.RawValue -and
|
||||
"$($dependencyItemObj.RawValue)" -ne 'NotConfigured' -and
|
||||
"$($dependencyItemObj.RawValue)" -ne 'False')
|
||||
}
|
||||
if (-not $passConstraint) { break }
|
||||
}
|
||||
|
||||
if (-not $passConstraint) { return }
|
||||
|
||||
if ($hasConstraint) { $objSetting.Level = $objSetting.Level + 1 }
|
||||
|
||||
# Attach recommended-value comparison (purely informational on the emitted row)
|
||||
$recommendedSetting = $Context.IntentCatRecommendedSettings[$objSetting.CategoryObject.Id] |
|
||||
Where-Object definitionId -EQ $objSetting.SettingId | Select-Object -First 1
|
||||
if ($recommendedSetting.valueJson -and ($objSetting.ValueSet -eq $false -or
|
||||
$recommendedSetting.valueJson -ne ($objSetting.RawValue | ConvertTo-Json -Depth 50 -Compress))) {
|
||||
$objSetting | Add-Member -MemberType NoteProperty -Name 'RecommendedValue' `
|
||||
-Value ($recommendedSetting.valueJson | ConvertFrom-Json) -Force
|
||||
}
|
||||
|
||||
$Context.AddSetting($objSetting)
|
||||
$script:_intentEmittedIds[[string]$objSetting.Id] = $true
|
||||
|
||||
if ($objSetting.ValueSet -eq $false) { return }
|
||||
|
||||
# Recurse: dependents (settings whose dependencies include this one)
|
||||
foreach ($depObj in ($script:_intentObjectSettings | Where-Object {
|
||||
$_.Dependencies.definitionId -eq $objSetting.SettingDefinition.Id
|
||||
})) {
|
||||
Add-IntentSettingObjectToList $depObj $Context
|
||||
}
|
||||
|
||||
# Recurse: children (settings with ParentId pointing at this one and no deps)
|
||||
foreach ($depObj in ($script:_intentObjectSettings | Where-Object {
|
||||
$_.ParentId -eq $objSetting.Id -and (($_.Dependencies | Measure-Object).Count -eq 0)
|
||||
})) {
|
||||
Add-IntentSettingObjectToList $depObj $Context
|
||||
}
|
||||
}
|
||||
|
||||
# Recursive setting parser. Builds a per-setting PSCustomObject with all the
|
||||
# metadata the emit step needs, pushes it onto $script:_intentObjectSettings,
|
||||
# and recurses into Complex / AbstractComplex / Collection children.
|
||||
function Get-IntentSettingInfo {
|
||||
param(
|
||||
$valueObj, $category, $defId, $allSettings, [DocumentationContext]$Context,
|
||||
[switch]$SkipConvertValue, [switch]$PassThru, $parentDef = $null
|
||||
)
|
||||
|
||||
$defObj = $category.settingDefinitions | Where-Object id -EQ $defId | Select-Object -First 1
|
||||
if (-not $defObj) { return }
|
||||
|
||||
$itemValue = $null
|
||||
$itemFullValue = $null
|
||||
|
||||
$rawValue = if ($SkipConvertValue) { $valueObj } else { $valueObj.valueJson | ConvertFrom-Json }
|
||||
|
||||
$valueSet = Get-IsIntentObjectConfigured $rawValue
|
||||
|
||||
if ($valueSet -eq $false) {
|
||||
# Skip child settings
|
||||
}
|
||||
elseif ($valueObj.'@odata.type' -eq '#microsoft.graph.deviceManagementCollectionSettingInstance' -or
|
||||
$defObj.'@odata.type' -eq '#microsoft.graph.deviceManagementComplexSettingDefinition' -or
|
||||
$defObj.valueType -eq 'collection') {
|
||||
$valueArr = @()
|
||||
$elementDefObj = if ($defObj.elementDefinitionId) {
|
||||
$category.settingDefinitions | Where-Object id -EQ $defObj.elementDefinitionId | Select-Object -First 1
|
||||
} else { $defObj }
|
||||
|
||||
if ($elementDefObj.propertyDefinitionIds) {
|
||||
# Each element is itself a record of N properties — emit the
|
||||
# FullValueTable so output providers can render it as a table.
|
||||
$itemFullValue = @()
|
||||
foreach ($tmpValue in $rawValue) {
|
||||
$htFullPropInfo = [ordered]@{}
|
||||
$arrValue = ''
|
||||
foreach ($propertyDefinitionId in $elementDefObj.propertyDefinitionIds) {
|
||||
$propDefObj = $category.settingDefinitions | Where-Object id -EQ $propertyDefinitionId | Select-Object -First 1
|
||||
if ($propDefObj.elementDefinitionId) {
|
||||
$propDefObj = $category.settingDefinitions | Where-Object id -EQ $propDefObj.elementDefinitionId | Select-Object -First 1
|
||||
}
|
||||
if ($arrValue) { $arrValue = $arrValue + $Context.PropertySeparator }
|
||||
$propName = $propertyDefinitionId.Split('_')[-1]
|
||||
$propValue = @()
|
||||
foreach ($childTmpValue in $tmpValue.$propName) {
|
||||
$propValue += Get-IntentObjectValue $propDefObj $childTmpValue
|
||||
}
|
||||
$colName = if ($propDefObj.displayName) { $propDefObj.displayName } else { $propName }
|
||||
$htFullPropInfo.Add($colName, $tmpValue.$propName)
|
||||
$arrValue = $arrValue + ($propValue -join $Context.PropertySeparator)
|
||||
}
|
||||
$itemFullValue += [PSCustomObject]$htFullPropInfo
|
||||
$valueArr += $arrValue
|
||||
}
|
||||
}
|
||||
elseif ($rawValue) {
|
||||
foreach ($tmpValue in $rawValue) {
|
||||
$valueArr += (Get-IntentObjectValue $elementDefObj $tmpValue)
|
||||
}
|
||||
}
|
||||
|
||||
if ($valueArr.Count -gt 0) {
|
||||
$itemValue = $valueArr -join $Context.ObjectSeparator
|
||||
}
|
||||
$valueSet = $valueArr.Count -gt 0
|
||||
}
|
||||
elseif ($valueObj.'@odata.type' -eq '#microsoft.graph.deviceManagementAbstractComplexSettingInstance' -or
|
||||
$defObj.'@odata.type' -eq '#microsoft.graph.deviceManagementAbstractComplexSettingDefinition') {
|
||||
$tmpDef = $category.settingDefinitions | Where-Object {
|
||||
$_.id -eq $rawValue.implementationId -or $_.id -eq $rawValue.'$implementationId'
|
||||
} | Select-Object -First 1
|
||||
if ($tmpDef) {
|
||||
$itemValue = $tmpDef.displayName
|
||||
}
|
||||
else {
|
||||
$valueSet = $false
|
||||
}
|
||||
}
|
||||
else {
|
||||
$itemValue = Get-IntentObjectValue $defObj $rawValue
|
||||
if (-not $itemValue) { $valueSet = $false }
|
||||
}
|
||||
|
||||
if ($valueSet -eq $false) {
|
||||
$itemValue = Get-LanguageString 'SettingDetails.notConfigured'
|
||||
$rawValue = $null
|
||||
}
|
||||
elseif (-not $itemValue) {
|
||||
$itemValue = $rawValue
|
||||
}
|
||||
|
||||
$curObjectInfo = [PSCustomObject]@{
|
||||
Name = $defObj.displayName
|
||||
Description = $defObj.description
|
||||
Category = $category.displayName
|
||||
CategoryDescription = $category.description
|
||||
CategoryObject = $category
|
||||
Value = $itemValue
|
||||
FullValueTable = $itemFullValue
|
||||
RawValue = $rawValue
|
||||
SettingDefinition = $defObj
|
||||
Dependencies = $defObj.dependencies
|
||||
ValueSet = $valueSet
|
||||
Id = [Guid]::NewGuid()
|
||||
ParentId = $null
|
||||
SettingId = $defObj.Id
|
||||
ParentSettingId = $parentDef.Id
|
||||
Level = 0
|
||||
}
|
||||
$script:_intentObjectSettings.Add($curObjectInfo)
|
||||
|
||||
if ($valueSet -eq $false) {
|
||||
# Skip children if value not set
|
||||
}
|
||||
elseif ($valueObj.'@odata.type' -eq '#microsoft.graph.deviceManagementComplexSettingInstance' -or
|
||||
$defObj.'@odata.type' -eq '#microsoft.graph.deviceManagementComplexSettingDefinition') {
|
||||
if ($valueObj.Value) {
|
||||
$isValueSet = $false
|
||||
if ($defObj.propertyDefinitionIds) {
|
||||
foreach ($childDefId in $defObj.propertyDefinitionIds) {
|
||||
$childSetting = $valueObj.Value | Where-Object DefinitionId -EQ $childDefId | Select-Object -First 1
|
||||
if ($childSetting) {
|
||||
$objValueInfo = Get-IntentSettingInfo $childSetting $category $childSetting.definitionId $allSettings $Context -PassThru -parentDef $defObj
|
||||
$objValueInfo.ParentId = $curObjectInfo.Id
|
||||
if (($objValueInfo.RawValue -is [bool] -and $objValueInfo.RawValue -eq $true) -or
|
||||
($objValueInfo.RawValue -is [string] -and -not [string]::IsNullOrEmpty($objValueInfo.RawValue) -and
|
||||
$objValueInfo.RawValue -ne 'notConfigured' -and -not [string]::IsNullOrEmpty($objValueInfo.Value)) -or
|
||||
($objValueInfo.RawValue -isnot [bool] -and $objValueInfo.RawValue -isnot [string])) {
|
||||
$isValueSet = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
foreach ($childSetting in $valueObj.Value) {
|
||||
$objValueInfo = Get-IntentSettingInfo $childSetting $category $childSetting.definitionId $allSettings $Context -PassThru -parentDef $defObj
|
||||
$objValueInfo.ParentId = $curObjectInfo.Id
|
||||
}
|
||||
$isValueSet = $true
|
||||
}
|
||||
}
|
||||
elseif ($rawValue -and $defObj.propertyDefinitionIds) {
|
||||
$isValueSet = $false
|
||||
$isDefault = $true
|
||||
foreach ($childDefId in $defObj.propertyDefinitionIds) {
|
||||
$propName = $childDefId.Split('_')[-1]
|
||||
$objValueInfo = Get-IntentSettingInfo $rawValue.$propName $category $childDefId $allSettings $Context -SkipConvertValue -PassThru -parentDef $defObj
|
||||
if ($objValueInfo.ValueSet -eq $true) { $isValueSet = $true }
|
||||
if ($objValueInfo.SettingDefinition.constraints -and
|
||||
$objValueInfo.SettingDefinition.constraints[0].'@odata.type' -eq '#microsoft.graph.deviceManagementEnumConstraint' -and
|
||||
($objValueInfo.SettingDefinition.constraints[0].values | Measure-Object).Count -gt 0) {
|
||||
if ($objValueInfo.SettingDefinition.constraints[0].values[0].value -ne $rawValue.$propName) {
|
||||
$isDefault = $false
|
||||
}
|
||||
}
|
||||
elseif ($objValueInfo.SettingDefinition.valueType -eq 'string') {
|
||||
if ($null -ne $rawValue.$propName) { $isDefault = $false }
|
||||
}
|
||||
elseif ($objValueInfo.SettingDefinition.valueType -eq 'boolean') {
|
||||
if ($false -ne $rawValue.$propName) { $isDefault = $false }
|
||||
}
|
||||
$objValueInfo.ParentId = $curObjectInfo.Id
|
||||
}
|
||||
if ($isDefault) { $isValueSet = $false }
|
||||
}
|
||||
else {
|
||||
$isValueSet = $false
|
||||
}
|
||||
|
||||
$curObjectInfo.Value = if ($isValueSet) { 'Configure' } else { Get-LanguageString 'SettingDetails.notConfigured' }
|
||||
$curObjectInfo.ValueSet = $isValueSet
|
||||
$curObjectInfo.FullValueTable = $null
|
||||
}
|
||||
elseif (($valueObj.'@odata.type' -eq '#microsoft.graph.deviceManagementAbstractComplexSettingInstance' -or
|
||||
$defObj.'@odata.type' -eq '#microsoft.graph.deviceManagementAbstractComplexSettingDefinition') -and
|
||||
$rawValue -and $tmpDef) {
|
||||
foreach ($childDefId in $tmpDef.propertyDefinitionIds) {
|
||||
$propName = $childDefId.Split('_')[-1]
|
||||
$objValueInfo = Get-IntentSettingInfo $rawValue.$propName $category $childDefId $allSettings $Context -SkipConvertValue -PassThru -parentDef $defObj
|
||||
$objValueInfo.ParentId = $curObjectInfo.Id
|
||||
}
|
||||
}
|
||||
|
||||
if ($PassThru) { $curObjectInfo }
|
||||
}
|
||||
|
||||
# Translates a raw setting value via its definition (enum / boolean / raw passthrough).
|
||||
function Get-IntentObjectValue {
|
||||
param($defObj, $rawValue)
|
||||
|
||||
if ($defObj.constraints.'@odata.type' -eq '#microsoft.graph.deviceManagementEnumConstraint') {
|
||||
$tmpOption = $defObj.constraints.Values | Where-Object value -EQ $rawValue | Select-Object -First 1
|
||||
if (-not $tmpOption -and $null -eq $rawValue) {
|
||||
# No defaultValue on the setting definition — fall back to first option.
|
||||
# Old-code wart preserved for golden parity.
|
||||
$tmpOption = $defObj.constraints.Values[0]
|
||||
}
|
||||
return $tmpOption.displayName
|
||||
}
|
||||
elseif ($defObj.valueType -eq 'boolean') {
|
||||
if ($rawValue -eq 'True') { return (Get-LanguageString 'SettingDetails.yes') }
|
||||
return $null
|
||||
}
|
||||
return $rawValue
|
||||
}
|
||||
|
||||
# Hook for custom "is configured?" rules. Old code always returns true; kept as
|
||||
# a function so type-specific overrides can be wired in later.
|
||||
function Get-IsIntentObjectConfigured {
|
||||
param($obj)
|
||||
return $true
|
||||
}
|
||||
|
||||
# Template-type to friendly category-name lookup. Used by BasicInfo "Type"
|
||||
# row when the input provider lands templateType resolution in v2; for now
|
||||
# only exported so handlers can reuse the mapping.
|
||||
function Get-IntentCategoryFromTemplateType {
|
||||
param([string]$TemplateType)
|
||||
|
||||
if (-not $TemplateType) {
|
||||
Write-Log 'Get-IntentCategoryFromTemplateType called with empty TemplateType' 2
|
||||
return $null
|
||||
}
|
||||
|
||||
# Captured before the prefix is stripped: whether the family was security-shaped
|
||||
# is what decides if failing to map it is worth reporting (see the default arm).
|
||||
$isSecurityFamily = $TemplateType.StartsWith('endpointSecurity') -or $TemplateType -match 'baseline'
|
||||
|
||||
if ($TemplateType.StartsWith('endpointSecurity')) {
|
||||
$TemplateType = $TemplateType.Substring(16)
|
||||
}
|
||||
|
||||
switch ($TemplateType) {
|
||||
'accountProtection' { return (Get-LanguageString 'SecurityTemplate.accountProtection') }
|
||||
'antivirus' { return (Get-LanguageString 'SecurityTemplate.antivirus') }
|
||||
'diskEncryption' { return (Get-LanguageString 'SecurityTemplate.diskEncryption') }
|
||||
'endpointDetectionReponse' { return (Get-LanguageString 'SecurityTemplate.eDR') }
|
||||
'attackSurfaceReduction' { return (Get-LanguageString 'SecurityTemplate.aSR') }
|
||||
'firewall' { return (Get-LanguageString 'SecurityTemplate.firewall') }
|
||||
{ $_ -in @('securityBaseline','baseline','advancedThreatProtectionSecurityBaseline','microsoftEdgeSecurityBaseline') } {
|
||||
return (Get-LanguageString 'Titles.securityBaselines')
|
||||
}
|
||||
# Not a security template, but it reaches this mapper the same way: the
|
||||
# Settings Catalog provider asks for a category name for every family it
|
||||
# documents, and the Apple ADE enrollment policies are this one. Without an
|
||||
# arm here the row read 'enrollmentConfiguration'. PolicySet.deviceEnrollment
|
||||
# is an existing key, so the label localizes with everything else.
|
||||
'enrollmentConfiguration' { return (Get-LanguageString 'PolicySet.deviceEnrollment') }
|
||||
default {
|
||||
# Only a security-shaped family is expected to resolve here. The Settings
|
||||
# Catalog provider (Get-IntentCategoryName) calls this for EVERY
|
||||
# templateFamily and documents the raw value when it does not map, so a
|
||||
# family like 'enrollmentConfiguration' is a normal outcome rather than a
|
||||
# problem - warning about it once per policy put a wall of yellow in the
|
||||
# log of any tenant with Apple ADE policies and buried the real signal.
|
||||
if ($isSecurityFamily) {
|
||||
Write-Log "Could not translate Intent Template type $TemplateType" 2
|
||||
}
|
||||
else {
|
||||
Write-LogDebug "No Intent category mapping for template family '$TemplateType'; documented as-is"
|
||||
}
|
||||
return $TemplateType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeIntentInput
|
||||
@@ -0,0 +1,125 @@
|
||||
# Manifest input provider.
|
||||
#
|
||||
# Bridges the old Documentation\ObjectInfo\ "manifest" JSON files (a flat
|
||||
# array of property descriptors describing how to translate a policy
|
||||
# object). Different from the Profile provider's category files, which:
|
||||
# - Live alongside as <category>_<policyType>.json
|
||||
# - Wrap their section array under a key matching the file basename
|
||||
# - Are looked up via ObjectCategories.json (Get-PolicyObjectCategoryInfo)
|
||||
#
|
||||
# Manifest files instead are:
|
||||
# - Flat top-level arrays
|
||||
# - Named directly by @odata.type: <odata.type>.json
|
||||
# e.g. #microsoft.graph.hardwareConfiguration.json
|
||||
# - Or named by PolicyType Id: #<typeId>.json
|
||||
# e.g. #Applications.json, #Autopilot.json
|
||||
#
|
||||
# These files exist for ~21 PolicyTypes that aren't catalogued in
|
||||
# ObjectCategories.json (Applications, AppProtection, BIOS hardware
|
||||
# configs, EnrollmentLimit/Notification/StatusPage, WindowsUpdate
|
||||
# profiles, MacScripts, PowerShell/HealthScripts, etc.) so without this
|
||||
# provider every one of those types renders an empty HTML stub.
|
||||
#
|
||||
# Match order: this file's basename ("Manifest") sorts before "Profile"
|
||||
# so it gets first shot at types that aren't already claimed by a
|
||||
# DocHandler or one of the specific schema providers (ADMX/Compliance V2
|
||||
# /Intent/SettingsCatalog).
|
||||
#
|
||||
# Old code reference: Extensions/Documentation.psm1:268-284 (the dispatcher
|
||||
# branches that test File.Exists on the two filename forms) +
|
||||
# Extensions/Documentation.psm1:4016 (Invoke-TranslateCustomProfileObject —
|
||||
# the helper that loaded a flat array and called Invoke-TranslateSection).
|
||||
|
||||
function Invoke-InitializeManifestInput {
|
||||
Add-DocumentationInputProvider ([PSCustomObject]@{
|
||||
Name = 'Manifest'
|
||||
Order = 10
|
||||
Match = {
|
||||
param($PolicyObject)
|
||||
$path = Get-DocumentationManifestPath $PolicyObject
|
||||
return [bool]$path
|
||||
}
|
||||
Translate = { param($PolicyObject, $Context) Invoke-TranslateManifestPolicyObject $PolicyObject $Context }
|
||||
})
|
||||
}
|
||||
|
||||
# Looks up a manifest file for $PolicyObject. Returns the resolved path or
|
||||
# $null. Tries @odata.type first, then PolicyType.Id with '#' prefix.
|
||||
function Get-DocumentationManifestPath {
|
||||
param($PolicyObject)
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
$dir = Join-Path $script:AppRootFolder 'Config\ObjectInfo'
|
||||
|
||||
$odata = [string]$obj.'@odata.type'
|
||||
if ($odata) {
|
||||
$path = Join-Path $dir "$odata.json"
|
||||
if (Test-Path -LiteralPath $path -PathType Leaf) {
|
||||
Write-Log "Manifest input provider: Found file based on OData type: $path"
|
||||
return $path
|
||||
}
|
||||
}
|
||||
|
||||
$typeId = $null
|
||||
if ($PolicyObject.PSObject.Properties['PolicyType'] -and $PolicyObject.PolicyType) {
|
||||
$typeId = [string]$PolicyObject.PolicyType.Id
|
||||
}
|
||||
if ($typeId) {
|
||||
$path = Join-Path $dir "#$typeId.json"
|
||||
if (Test-Path -LiteralPath $path -PathType Leaf) {
|
||||
Write-Log "Manifest input provider: Found file based on PolicyType.Id: $path"
|
||||
return $path
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Invoke-TranslateManifestPolicyObject {
|
||||
param($PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
$path = Get-DocumentationManifestPath $PolicyObject
|
||||
if (-not $path) { return }
|
||||
|
||||
# Header rows (matches Profile provider so output looks identical for
|
||||
# both code paths — manifest vs category-driven).
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
|
||||
# Add app name for apps
|
||||
$appType = Get-GraphApplicationType $PolicyObject
|
||||
if($appType)
|
||||
{
|
||||
$appTypeName = Get-LanguageString "AppType.$($appType.LanguageId)"
|
||||
if($appTypeName) { Add-BasicPropertyValue (Get-LanguageString "Inputs.installationSourceLabel") $appTypeName }
|
||||
}
|
||||
|
||||
$Context.CurrentObject = $obj
|
||||
Initialize-DocumentationObjectInfoObject $obj
|
||||
|
||||
try {
|
||||
$manifest = [IO.File]::ReadAllText($path) | ConvertFrom-Json
|
||||
} catch {
|
||||
Write-LogError "Failed to read manifest $path" $_.Exception
|
||||
return
|
||||
}
|
||||
|
||||
if (-not $manifest) { return }
|
||||
|
||||
# Manifest is a flat array (no per-file wrapper key), so pass it directly
|
||||
# to the walker. No $ObjInfo - the manifest doesn't come from
|
||||
# ObjectCategories.json.
|
||||
try {
|
||||
$Context.CurrentSubCategory = ''
|
||||
Invoke-TranslateSection $obj $manifest $null
|
||||
} catch {
|
||||
Write-LogError "Failed to translate manifest $(Split-Path -Leaf $path)" $_.Exception
|
||||
}
|
||||
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
|
||||
}
|
||||
|
||||
Invoke-InitializeManifestInput
|
||||
@@ -0,0 +1,195 @@
|
||||
# Generic Profile input provider.
|
||||
#
|
||||
# Ported from old Extensions/Documentation.psm1:2057 (Invoke-TranslateProfile-
|
||||
# Object). Claims any @odata.type catalogued in Config/ObjectCategories.json
|
||||
# that isn't already handled by a more-specific input provider or handler
|
||||
# (first-match-wins dispatch in [DocumentationRegistry]).
|
||||
#
|
||||
# For each catalogued type:
|
||||
# 1. Emit BasicInfo via Add-BasicDefaultValues (which itself reads
|
||||
# ObjectCategories.json for Platform-supported + Profile-type rows)
|
||||
# 2. Emit Created/Modified/Version via Add-BasicAdditionalValues
|
||||
# 3. Find category files: either the explicit Categories list, or every
|
||||
# file matching *_<policyType>.json under Config/ObjectInfo/
|
||||
# 4. Load each as JSON, dispatch to Invoke-TranslateSection walker
|
||||
#
|
||||
# The walker handles all the per-prop dataType dispatching to translate
|
||||
# primitives (Boolean/Option/MultiOption/Table/Duration etc.).
|
||||
|
||||
function Invoke-InitializeProfileInput {
|
||||
Add-DocumentationInputProvider ([PSCustomObject]@{
|
||||
Name = 'Profile'
|
||||
Order = 60
|
||||
Match = {
|
||||
param($PolicyObject)
|
||||
$odata = $PolicyObject.JsonObject.'@odata.type'
|
||||
if (-not $odata) { return $false }
|
||||
if (-not (Get-Command Get-PolicyObjectCategoryInfo -ErrorAction SilentlyContinue)) { return $false }
|
||||
$info = Get-PolicyObjectCategoryInfo $odata
|
||||
return ($null -ne $info -and $null -ne $info.PolicyType)
|
||||
}
|
||||
Translate = { param($PolicyObject, $Context) Invoke-TranslateProfilePolicyObject $PolicyObject $Context }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-TranslateProfilePolicyObject {
|
||||
param($PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
$objInfo = Get-PolicyObjectCategoryInfo $obj.'@odata.type'
|
||||
if (-not $objInfo) { return }
|
||||
|
||||
# Header rows
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
# Pin '@ObjectFromFile' so the walker's source-unavailable branches (linked
|
||||
# certificates etc.) treat the input as a file-based object even when
|
||||
# SourceTenantUnavailable isn't set.
|
||||
if (-not $obj.PSObject.Properties['@ObjectFromFile']) {
|
||||
$obj | Add-Member -MemberType NoteProperty -Name '@ObjectFromFile' -Value $true -Force
|
||||
}
|
||||
$Context.CurrentObject = $obj
|
||||
Initialize-DocumentationObjectInfoObject $obj
|
||||
|
||||
# Resolve the list of ObjectInfo JSON files to walk for this PolicyType
|
||||
$objectInfoDir = Join-Path $script:AppRootFolder 'Config\ObjectInfo'
|
||||
$allFiles = @()
|
||||
|
||||
if ($objInfo.Categories -and $objInfo.Categories.Count -gt 0) {
|
||||
foreach ($cat in $objInfo.Categories) {
|
||||
$path = Join-Path $objectInfoDir "$($cat.ToLower())_$($objInfo.PolicyType.ToLower()).json"
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
$allFiles += [IO.FileInfo]$path
|
||||
}
|
||||
else {
|
||||
Write-Log "ObjectInfo file '$path' not found for $($objInfo.PolicyType)" 2
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Single-file path — find any *_<policyType>.json
|
||||
$pattern = "*_$($objInfo.PolicyType.ToLower()).json"
|
||||
if (Test-Path $objectInfoDir) {
|
||||
$files = Get-ChildItem -Path $objectInfoDir -Filter $pattern -ErrorAction SilentlyContinue
|
||||
if (-not $files) {
|
||||
Write-Log "No ObjectInfo files matching '$pattern' for $($objInfo.PolicyType)" 2
|
||||
}
|
||||
foreach ($f in $files) { $allFiles += $f }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($fi in $allFiles) {
|
||||
try {
|
||||
$categoryObj = [IO.File]::ReadAllText($fi.FullName) | ConvertFrom-Json
|
||||
$Context.CurrentSubCategory = ''
|
||||
# Per-file custom handlers override the generic walker (old code:
|
||||
# Invoke-CDDocumentTranslateSectionFile, called via docProvider.
|
||||
# TranslateSectionFile hook from Invoke-TranslateProfileObject).
|
||||
# Returns $true if the custom handler emitted rows; $false to fall
|
||||
# through to Invoke-TranslateSection.
|
||||
if (Invoke-DocCustomSectionFileTranslator -Obj $obj -FileInfo $fi -CategoryObj $categoryObj -ObjInfo $objInfo) {
|
||||
continue
|
||||
}
|
||||
# Each ObjectInfo file wraps its section array under a key matching the file's basename
|
||||
$sections = $categoryObj."$($fi.BaseName)"
|
||||
if ($sections) {
|
||||
Invoke-TranslateSection $obj $sections $objInfo
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to translate ObjectInfo file $($fi.Name)" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Custom per-(odata.type, fileBaseName) section-file translators. Mirrors old
|
||||
# Extensions/DocumentationCustom.psm1's Invoke-CDDocumentTranslateSectionFile.
|
||||
# Each block returns $true if it emitted rows and the generic walker should be
|
||||
# skipped for this file, $false to fall through.
|
||||
function Invoke-DocCustomSectionFileTranslator {
|
||||
param($Obj, [IO.FileInfo]$FileInfo, $CategoryObj, $ObjInfo)
|
||||
|
||||
# --- Compliance: Custom Compliance category (Windows 10) ----------------
|
||||
# Generic walker can't emit useful rows for `customcompliance_compliancewindows10`
|
||||
# because the manifest's dataType=25 ("home screen", unused) is the child
|
||||
# carrying the actual content, and the rules live on a separate
|
||||
# $obj.deviceCompliancePolicyScript navigation property rather than on the
|
||||
# boolean entityKey the parent points to. Three rows are emitted by hand:
|
||||
# - Custom compliance (Require / Not configured)
|
||||
# - Select your discovery script (resolved displayName)
|
||||
# - Upload and validate the JSON file (base64-decoded rulesContent)
|
||||
if ($Obj.'@odata.type' -eq '#microsoft.graph.windows10CompliancePolicy' -and
|
||||
$FileInfo.BaseName -eq 'customcompliance_compliancewindows10') {
|
||||
|
||||
$category = Get-PolicyObjectCategoryString ($CategoryObj."$($FileInfo.BaseName)".category)
|
||||
|
||||
if ($null -eq $Obj.deviceCompliancePolicyScript) {
|
||||
$propValue = Get-LanguageString 'BooleanActions.notConfigured'
|
||||
$rawValue = 'notConfigured'
|
||||
} else {
|
||||
$propValue = Get-LanguageString 'BooleanActions.require'
|
||||
$rawValue = 'require'
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.adminConfiguredComplianceSettingName'
|
||||
Value = $propValue
|
||||
EntityKey = 'deviceCompliancePolicyScript'
|
||||
RawValue = $rawValue
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
if ($Obj.deviceCompliancePolicyScript) {
|
||||
# Resolve script displayName via shared cache. Offline runs / cache
|
||||
# miss fall back to the script id so the row isn't blank.
|
||||
$scriptId = [string]$Obj.deviceCompliancePolicyScript.deviceComplianceScriptId
|
||||
$scriptName = $scriptId
|
||||
if (-not [string]::IsNullOrEmpty($scriptId)) {
|
||||
$cache = Get-CacheObject 'DocAllCustomCompliancePolicies'
|
||||
# Custom compliance scripts are authored in the source tenant (not
|
||||
# generic schema), so this is gated on source-tenant availability.
|
||||
if (-not $cache -and -not (Get-CurrentDocumentationContext).SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$cache = @((Invoke-MSGraphAPI -Url "/deviceManagement/deviceComplianceScripts?`$select=displayName,id" -ODataMetadata 'minimal').value)
|
||||
Set-CacheObject 'DocAllCustomCompliancePolicies' $cache
|
||||
} catch {
|
||||
Write-Log "Failed to fetch deviceComplianceScripts for resolution: $($_.Exception.Message)" 2
|
||||
}
|
||||
}
|
||||
if ($cache) {
|
||||
$match = $cache | Where-Object Id -EQ $scriptId | Select-Object -First 1
|
||||
if ($match.displayName) { $scriptName = $match.displayName }
|
||||
}
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'CustomCompliance.FilePicker.scriptFileLabel'
|
||||
Value = $scriptName
|
||||
EntityKey = 'deviceComplianceScriptName'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
if ($Obj.deviceCompliancePolicyScript.rulesContent) {
|
||||
$rules = try {
|
||||
[System.Text.Encoding]::UTF8.GetString(
|
||||
[System.Convert]::FromBase64String($Obj.deviceCompliancePolicyScript.rulesContent))
|
||||
} catch {
|
||||
[string]$Obj.deviceCompliancePolicyScript.rulesContent
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'CustomCompliance.UploadFile.jsonFileLabel'
|
||||
Value = $rules
|
||||
EntityKey = 'jsonFileContent'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
Invoke-InitializeProfileInput
|
||||
@@ -0,0 +1,142 @@
|
||||
# Settings Catalog input provider.
|
||||
#
|
||||
# Ported from old Extensions/Documentation.psm1:1107 (Invoke-TranslateSettings-
|
||||
# Object, ~100 LOC). Claims @odata.type='#microsoft.graph.deviceManagement
|
||||
# ConfigurationPolicy' and translates the policy's settings via the recursive
|
||||
# walker (Add-SettingsSetting in SettingsCatalogWalker.ps1).
|
||||
#
|
||||
# Live Graph dependencies (resolved through Invoke-MSGraphAPI):
|
||||
# /deviceManagement/configurationPolicies/{id}/settings?$expand=settingDefinitions
|
||||
# /deviceManagement/configurationCategories?$filter=platforms has 'windows10' and technologies has 'mdm'
|
||||
# /deviceManagement/configurationSettings/{id} (per-setting fallback when defs aren't expanded)
|
||||
#
|
||||
# These are batch-cached on the [DocumentationContext] ($ctx.CfgCategories,
|
||||
# $ctx.CachedCfgSettings) so a bulk run pays the cost once. The per-policy
|
||||
# settings fetch (by id) is source-tenant-specific and skipped when
|
||||
# $ctx.SourceTenantUnavailable; the GENERIC schema (setting definitions via
|
||||
# the walker's configurationSettings/{id} fallback, and configurationCategories)
|
||||
# is still resolved from any connected tenant (Test-DocumentationGraphAvailable).
|
||||
# With no tenant at all the provider still runs, producing raw IDs.
|
||||
#
|
||||
# OFFLINE SMOKE TEST DEFERRED: golden-file validation against the provided
|
||||
# fixture (C:/Intune/OldDocumentation/SettingsCatalog/[Testing] Windows 11
|
||||
# Settings.json) needs the policy re-exported with $expand=settings($expand=
|
||||
# settingDefinitions) + a sidecar fixture for scope tags. Until then this
|
||||
# provider is exercised live against a tenant; its structure mirrors the old
|
||||
# code's so trust-the-port applies.
|
||||
|
||||
function Invoke-InitializeSettingsCatalogInput {
|
||||
Add-DocumentationInputProvider ([PSCustomObject]@{
|
||||
Name = 'SettingsCatalog'
|
||||
Order = 20
|
||||
Match = { param($PolicyObject) $PolicyObject.JsonObject.'@odata.type' -eq '#microsoft.graph.deviceManagementConfigurationPolicy' }
|
||||
Translate = { param($PolicyObject, $Context) Invoke-TranslateSettingsCatalogObject $PolicyObject $Context }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-TranslateSettingsCatalogObject {
|
||||
param($PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# --- BasicInfo header rows ---
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'ConfigurationTypes.settingsCatalog') '@odata.type'
|
||||
|
||||
if ($obj.templateReference.templateId) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.Category') (Get-IntentCategoryName $obj.templateReference.templateFamily) 'templateFamily'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.policyType') $obj.templateReference.templateDisplayName 'templateDisplayName'
|
||||
}
|
||||
|
||||
if ($obj.platforms) {
|
||||
$platformType = Get-LanguageString "Platform.$($obj.platforms)"
|
||||
if ($platformType) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.platformSupported') $platformType 'platforms'
|
||||
}
|
||||
}
|
||||
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
# --- Settings ---
|
||||
# Prefer in-policy settings (export / hydrate with $expand=settings has them
|
||||
# inline). When settingDefinitions are not also inline — the hydrate body URL
|
||||
# only does `?$expand=Settings`, NOT `?$expand=Settings($expand=settingDefinitions)`
|
||||
# — the SettingsCatalog walker falls back to a sequential per-setting
|
||||
# /configurationSettings/{id} GET (one round-trip per settingInstance),
|
||||
# which scales linearly with setting count and crushes bulk-doc runs.
|
||||
# One enrich call per policy collapses that N+1 to a single per-policy call.
|
||||
$cfgSettings = @()
|
||||
if ($obj.Settings -and ($obj.Settings | Measure-Object).Count -gt 0) {
|
||||
$cfgSettings = @($obj.Settings)
|
||||
}
|
||||
|
||||
$hasDefs = $false
|
||||
foreach ($s in $cfgSettings) {
|
||||
if ($s.settingDefinitions -and ($s.settingDefinitions | Measure-Object).Count -gt 0) {
|
||||
$hasDefs = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Bulk runs: Initialize-DocumentationRunPrefetch already fetched these in
|
||||
# one Graph $batch — consume from the per-run cache (authoritative for this
|
||||
# run, even when empty, so an empty-settings policy doesn't trigger a
|
||||
# redundant live GET). The live GET below is the lazy fallback for the
|
||||
# single-policy Get-GraphDocumentation path.
|
||||
if (-not $hasDefs -and $Context.PrefetchedPolicySettings.ContainsKey([string]$obj.Id)) {
|
||||
$cfgSettings = @($Context.PrefetchedPolicySettings[[string]$obj.Id])
|
||||
$hasDefs = $true
|
||||
}
|
||||
|
||||
# Source-tenant-specific: fetches THIS policy's settings by id, which 404s on
|
||||
# any other tenant. Stays gated on -not SourceTenantUnavailable. When the
|
||||
# source is gone but the export carries settings inline (no defs), the walker's
|
||||
# generic per-setting configurationSettings/{id} fallback resolves the schema.
|
||||
if (-not $hasDefs -and -not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$headers = @{}
|
||||
if ($Context.Language -and $Context.Language -ne 'en') {
|
||||
$headers['Accept-Language'] = $Context.Language
|
||||
}
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/configurationPolicies('$($obj.Id)')/settings?`$expand=settingDefinitions&`$top=1000" -AdditionalHeaders $headers -ODataMetadata 'minimal'
|
||||
if ($resp -and $resp.Value) {
|
||||
$cfgSettings = @($resp.Value)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to fetch settings for policy $($obj.Id)" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
if ($cfgSettings.Count -eq 0) {
|
||||
Write-Log "SettingsCatalog: no settings to document for $($obj.name)" 2
|
||||
return
|
||||
}
|
||||
|
||||
# Schema caching, the walk and the (Category, SubCategory) grouping are shared
|
||||
# with the MAM app-configuration handler - see
|
||||
# Get-SettingsCatalogDocumentationRows in Core/SettingsCatalogWalker.ps1.
|
||||
foreach ($row in (Get-SettingsCatalogDocumentationRows $cfgSettings $Context)) {
|
||||
$Context.AddSetting($row)
|
||||
}
|
||||
|
||||
Invoke-DocumentationSettingsCatalogPostProcess $obj $Context
|
||||
}
|
||||
|
||||
# Settings Catalog uses an intent-style category mapping that's distinct from
|
||||
# Get-DocObjectTypeString (which is for group/category headers in the OUTPUT,
|
||||
# not for BasicInfo rows). Delegates to the Intent provider's
|
||||
# Get-IntentCategoryFromTemplateType (the port of old Documentation.psm1:1523
|
||||
# Get-IntentCategory), so endpoint-security-family catalogs show the localized
|
||||
# category name instead of the raw templateFamily (e.g. endpointSecurityAntivirus).
|
||||
function Get-IntentCategoryName {
|
||||
param($TemplateType)
|
||||
if (-not $TemplateType) { return '' }
|
||||
if (Get-Command Get-IntentCategoryFromTemplateType -ErrorAction SilentlyContinue) {
|
||||
$mapped = Get-IntentCategoryFromTemplateType $TemplateType
|
||||
if ($mapped) { return $mapped }
|
||||
}
|
||||
if ($TemplateType -is [string]) { return $TemplateType }
|
||||
return "$TemplateType"
|
||||
}
|
||||
|
||||
Invoke-InitializeSettingsCatalogInput
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,589 @@
|
||||
# Atlassian (Confluence Storage Format) output provider.
|
||||
#
|
||||
# Emits Confluence-compatible XHTML for pasting into a Confluence page editor
|
||||
# (Rich Text -> Source view) or POSTing via the Confluence REST API as
|
||||
# `representation=storage`. Structurally identical to the HTML provider
|
||||
# (BasicInfo / FilteredSettings / ComplianceActions / ApplicabilityRules /
|
||||
# Assignments / CustomTables + a per-run table of contents), only the emitted
|
||||
# markup differs: no CSS embed, no <HTML>/<body> wrapper, code and long-text
|
||||
# blocks use Confluence macros (<ac:structured-macro name='code'|'expand'>).
|
||||
#
|
||||
# Heading anchors: every heading carries id='<anchor>' plus an inline `anchor`
|
||||
# macro of the same name, and the table of contents links that name. Anchors are
|
||||
# positional - 'section-N' for every heading, numbered in emission order across
|
||||
# the whole run (including headings kept out of the TOC),
|
||||
# so a name is never reused. 'table-N' is reserved for the -ToT caption form,
|
||||
# which no call site in this provider currently uses - table captions are plain
|
||||
# level-6 headings here, as in the HTML provider. The id= attribute is a
|
||||
# documented contract for consumers that parse the generated file before it is
|
||||
# published (Confluence itself discards the attribute); it always equals the
|
||||
# macro name. Changing the naming scheme is a breaking change for those
|
||||
# consumers.
|
||||
#
|
||||
# Options (via $Options.Outputs.atlassian):
|
||||
# AtlassianDocumentName - target file path. Supports Expand-FileName
|
||||
# tokens (%MyDocuments%, %Organization%, %Date%,
|
||||
# %DateTime%). Default: %MyDocuments%\%Organization%-%Date%.html
|
||||
# AtlassianDocumentFileType - 'Full' (single file) or 'Object' (one file per
|
||||
# policy + a TOC index file). Default: 'Full'.
|
||||
# AtlassianTitleProperty - H1 title of the index page. Default: 'Intune documentation'.
|
||||
# AtlassianOpenFile - After writing, launch the file with the OS
|
||||
# default handler. Set $false for CI runs.
|
||||
# Default: $true.
|
||||
|
||||
function Invoke-InitializeAtlassianOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "Atlassian"
|
||||
Value = "atlassian"
|
||||
# Path metadata (see DocumentationOutputHTML.ps1 header comment). Drives
|
||||
# the "default output folder" inference on the bulk-doc form, whose
|
||||
# Atlassian options panel mirrors the HTML one minus the CSS row.
|
||||
PrimaryPathOption = "AtlassianDocumentName"
|
||||
PathIsFolder = $false
|
||||
PreProcess = { Invoke-AtlassianPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-AtlassianNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-AtlassianNewObjectType @args }
|
||||
Process = { Invoke-AtlassianProcessItem @args }
|
||||
PostProcess = { Invoke-AtlassianPostProcessItems @args }
|
||||
ProcessAllObjects = { Invoke-AtlassianProcessAllObjects @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-AtlassianPreProcessItems {
|
||||
$script:atlSectionAnchors = @()
|
||||
$script:atlTotAnchors = @()
|
||||
# Anchor numbering is deliberately NOT derived from the anchor lists above:
|
||||
# a -SkipTOC heading is emitted (and needs an anchor) without being listed,
|
||||
# so a list-derived number would be handed out twice. See Add-AtlassianHeader.
|
||||
$script:atlSectionCount = 0
|
||||
$script:atlTotCount = 0
|
||||
$script:atlBody = $null
|
||||
$script:atlCurrentItemFileName = $null
|
||||
|
||||
$fileName = Get-DocumentationOutputOption atlassian "AtlassianDocumentName" ""
|
||||
if (-not $fileName) { $fileName = "%MyDocuments%\%Organization%-%Date%.html" }
|
||||
$fileName = Expand-FileName $fileName
|
||||
|
||||
$script:atlOutFile = $fileName
|
||||
$script:atlDocumentPath = [IO.Path]::GetDirectoryName($fileName)
|
||||
$script:atlOutputType = Get-DocumentationOutputOption atlassian "AtlassianDocumentFileType" "Full"
|
||||
|
||||
if ($script:atlOutputType -eq "Object") {
|
||||
Write-Log "Atlassian: document one file for each object + index file"
|
||||
}
|
||||
else {
|
||||
Write-Log "Atlassian: document one single file for all objects"
|
||||
$script:atlOutputType = "Full"
|
||||
$script:atlBody = [System.Text.StringBuilder]::new()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-AtlassianPostProcessItems {
|
||||
$userName = $null
|
||||
$mail = ""
|
||||
$me = Get-CurrentUser
|
||||
if ($me) {
|
||||
if ($me.givenName -and $me.surname) {
|
||||
$userName = "$($me.givenName) $($me.surname)"
|
||||
}
|
||||
else {
|
||||
$userName = $me.displayName
|
||||
}
|
||||
if ($me.mail) { $mail = " ($($me.mail))" }
|
||||
}
|
||||
|
||||
$orgName = Get-CurrentOrganizationName
|
||||
|
||||
$title = Get-DocumentationOutputOption atlassian "AtlassianTitleProperty" "Intune documentation"
|
||||
if (-not $title) { $title = "Intune documentation" }
|
||||
|
||||
# Escaped for the same reason heading text is: an '&' in the tenant's
|
||||
# organization name or in the configured title would make the document body
|
||||
# invalid XML, and Confluence rejects the upload of the whole page.
|
||||
$content = [System.Text.StringBuilder]::new()
|
||||
[void]$content.AppendLine("<h1>$(Get-AtlassianXmlText $title)</h1>")
|
||||
|
||||
if (-not ((Get-DocumentationOption "SkipDocumentInfo" $false) -eq $true)) {
|
||||
if ($orgName) { [void]$content.AppendLine("Organization: $(Get-AtlassianXmlText $orgName)") }
|
||||
if ($userName) { [void]$content.AppendLine("Generated by: $(Get-AtlassianXmlText "$userName$mail")") }
|
||||
[void]$content.AppendLine("Generated: $((Get-Date).ToShortDateString()) $((Get-Date).ToLongTimeString())")
|
||||
}
|
||||
|
||||
if ($script:atlSectionAnchors.Count -gt 0) {
|
||||
[void]$content.AppendLine("<h2>Table of Contents</h2>")
|
||||
Add-AtlassianTableOfContents $content
|
||||
}
|
||||
|
||||
$text = $content.ToString()
|
||||
if ($script:atlOutputType -eq "Full" -and $script:atlBody) {
|
||||
$text += $script:atlBody.ToString()
|
||||
}
|
||||
|
||||
Save-DocumentationFile $text $script:atlOutFile -OpenFile:((Get-DocumentationOutputOption atlassian "AtlassianOpenFile" $true) -eq $true)
|
||||
}
|
||||
|
||||
function Invoke-AtlassianNewObjectGroup {
|
||||
param($groupId)
|
||||
$script:atlObjectHeaderLevel = 2
|
||||
Add-AtlassianHeader (Get-DocObjectTypeString $groupId)
|
||||
}
|
||||
|
||||
function Invoke-AtlassianNewObjectType {
|
||||
param($objectTypeName)
|
||||
$script:atlObjectHeaderLevel = 3
|
||||
Add-AtlassianHeader $objectTypeName
|
||||
$script:atlObjectHeaderLevel = 4
|
||||
}
|
||||
|
||||
function Invoke-AtlassianProcessAllObjects {
|
||||
param($documentationInfo)
|
||||
# ScopeTags consolidated table is deferred (matches HTML provider stub).
|
||||
}
|
||||
|
||||
function Invoke-AtlassianProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
# A documented object may ask to be titled by something other than its display
|
||||
# name (see Get-DocumentationDisplayName). Headings and captions follow it; the
|
||||
# file name below deliberately does not.
|
||||
$objName = Get-DocumentationDisplayName $PolicyObject $documentedObj
|
||||
$script:docDisplayName = $objName
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
if ($script:atlOutputType -eq "Object") {
|
||||
# Table numbering restarts per file (each object is its own page), section
|
||||
# numbering does not - see the header comment on anchor uniqueness.
|
||||
$script:atlTotAnchors = @()
|
||||
$script:atlTotCount = 0
|
||||
$script:atlBody = [System.Text.StringBuilder]::new()
|
||||
$script:atlCurrentItemFileName = Get-AtlassianObjectFileName $PolicyObject
|
||||
}
|
||||
|
||||
Add-AtlassianHeader $objName
|
||||
|
||||
try {
|
||||
foreach ($tableType in @("BasicInfo","FilteredSettings")) {
|
||||
if ($tableType -eq "BasicInfo") {
|
||||
$properties = @("Name","Value")
|
||||
$lngId = "SettingDetails.basics"
|
||||
}
|
||||
else {
|
||||
$properties = if ($documentedObj.DefaultDocumentationProperties) {
|
||||
$documentedObj.DefaultDocumentationProperties
|
||||
} else {
|
||||
@("Name","Value")
|
||||
}
|
||||
$lngId = "TableHeaders.settings"
|
||||
}
|
||||
|
||||
if (($documentedObj.$tableType | Measure-Object).Count -gt 0) {
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $documentedObj.$tableType $properties $lngId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $documentedObj.ComplianceActions @("Action","Schedule","MessageTemplate","EmailCC") "Category.complianceActionsLabel"
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $documentedObj.ApplicabilityRules @("Rule","Property","Value") "SettingDetails.applicabilityRules"
|
||||
}
|
||||
|
||||
Add-AtlassianObjectScripts $documentedObj
|
||||
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Sort-Object -Property Order)) {
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
if ($documentedObj.Assignments[0].RawIntent) {
|
||||
$properties = @("GroupMode","Group","Filter","FilterMode")
|
||||
$settingsObj = $documentedObj.Assignments | Where-Object { $null -ne $_.Settings } | Select-Object -First 1
|
||||
if ($settingsObj) {
|
||||
foreach ($objProp in $settingsObj.Settings.Keys) {
|
||||
if ($objProp -in $properties) { continue }
|
||||
if ($objProp -in @("Category","RawIntent")) { continue }
|
||||
$properties += "Settings.$objProp"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$hasFilter = $false
|
||||
foreach ($a in $documentedObj.Assignments) {
|
||||
if ($a.PSObject.Properties.Name -contains "FilterMode") { $hasFilter = $true; break }
|
||||
}
|
||||
$properties = @("Group")
|
||||
if ($hasFilter) { $properties += @("Filter","FilterMode") }
|
||||
}
|
||||
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $documentedObj.Assignments $properties "TableHeaders.assignments" -AddCategories
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
|
||||
if ($script:atlOutputType -eq "Object") {
|
||||
$fileName = Join-Path $script:atlDocumentPath $script:atlCurrentItemFileName
|
||||
Save-DocumentationFile $script:atlBody.ToString() $fileName
|
||||
$script:atlBody = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AtlassianObjectFileName {
|
||||
param($PolicyObject)
|
||||
|
||||
$objName = if ($PolicyObject.Name) { [string]$PolicyObject.Name } else { 'Unnamed policy' }
|
||||
$id = if ($PolicyObject.Id) { [string]$PolicyObject.Id } else { $null }
|
||||
$typeId = if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.Id) { [string]$PolicyObject.PolicyType.Id } else { $null }
|
||||
$suffix = if ($typeId -and $id) { " [$typeId-$id]" }
|
||||
elseif ($id) { " [$id]" }
|
||||
else { '' }
|
||||
return Remove-InvalidFileNameChars "$objName$suffix.html"
|
||||
}
|
||||
|
||||
# Escape text for storage format. Confluence storage format is strict XML and
|
||||
# declares only the five XML built-in entities, so a bare '&' or '<' arriving
|
||||
# from tenant data (policy names, localized captions) makes the whole document
|
||||
# body malformed and Confluence rejects the upload - not just that heading.
|
||||
# Values inside table cells go through Set-AtlassianText, which does this plus
|
||||
# the code/expand macro wrapping; headers and TOC labels need only the escape.
|
||||
function Get-AtlassianXmlText {
|
||||
param([string]$Text)
|
||||
|
||||
if (-not $Text) { return "" }
|
||||
return $Text.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
|
||||
# The author-controlled link target for a heading.
|
||||
#
|
||||
# Confluence strips author-specified id= attributes when it converts storage
|
||||
# format to ADF, so an id alone is not linkable - '#name' only ever resolves to
|
||||
# an anchor macro or to Confluence's own heading-text-derived anchor.
|
||||
#
|
||||
# The macro stays inline inside the heading until the downstream rewrite observed
|
||||
# in Docs/AtlassianAnchorVerification-2026-09-15.md has been attributed. Moving it
|
||||
# to a preceding paragraph before that measurement was an unverified fix that could
|
||||
# add a blank line at every heading without changing the publisher's output. Inline
|
||||
# placement is legal ADF (`anchor` is an inline macro and headings accept inline
|
||||
# content), keeps the jump target on the heading, and is the known baseline while
|
||||
# the runbook and Confluence import paths are tested separately.
|
||||
#
|
||||
# Attributes are single-quoted like every other macro in this file. Consumers
|
||||
# JSON-escape the document body before publishing it, and a double-quoted
|
||||
# attribute arrives as ac:name=\"anchor\" and breaks the macro.
|
||||
function Get-AtlassianAnchorMacro {
|
||||
param([string]$Name)
|
||||
|
||||
if (-not $Name) { return "" }
|
||||
return "<ac:structured-macro ac:name='anchor' ac:schema-version='1'>" +
|
||||
"<ac:parameter ac:name=''>$Name</ac:parameter>" +
|
||||
"</ac:structured-macro>"
|
||||
}
|
||||
|
||||
# Build the href for a TOC entry: a percent-encoded relative file name plus the
|
||||
# anchor fragment, safe to drop into a single-quoted attribute.
|
||||
#
|
||||
# In 'Object' mode the file name comes from the policy name (see
|
||||
# Get-AtlassianObjectFileName), and only path-invalid characters are stripped
|
||||
# from it. '&', '<' and "'" therefore survive - one of them makes the whole
|
||||
# document body malformed XML, or terminates the attribute - and a '#' in a
|
||||
# policy name would open a second fragment and retarget the link. Percent-encode
|
||||
# the file-name component (never the '#' that separates the fragment), then
|
||||
# XML-escape what is left.
|
||||
function Get-AtlassianHref {
|
||||
param([string]$FileName, [string]$Anchor)
|
||||
|
||||
$target = ""
|
||||
if ($FileName) {
|
||||
# A bare file name, no directory separators, so encoding the whole string
|
||||
# is correct. EscapeDataString covers ' on .NET Core but not on every
|
||||
# .NET Framework version; the explicit replace is a no-op when it did.
|
||||
$target = [Uri]::EscapeDataString($FileName).Replace("'", "%27")
|
||||
}
|
||||
|
||||
return Get-AtlassianXmlText "$target#$Anchor"
|
||||
}
|
||||
|
||||
function Add-AtlassianHeader {
|
||||
param(
|
||||
[string]$HeaderText,
|
||||
[int]$Level = $script:atlObjectHeaderLevel,
|
||||
[switch]$ToT,
|
||||
[switch]$SkipTOC
|
||||
)
|
||||
|
||||
if ($ToT) {
|
||||
# 'Table N. ' is a visible caption prefix - that is what the Markdown
|
||||
# provider does with it. It used to be prepended to the id instead of to
|
||||
# the text, producing id="Table 1. table-1": spaces and a period in an
|
||||
# identifier, and no visible numbering anywhere. The number matches the
|
||||
# 'table-N' anchor below.
|
||||
$HeaderText = "Table $($script:atlTotCount + 1). $HeaderText"
|
||||
}
|
||||
|
||||
if ($script:atlBody) {
|
||||
# Every heading that reaches the body consumes a number, whether or not it
|
||||
# is listed in the TOC. Numbering off $atlSectionAnchors.Count instead gave
|
||||
# a -SkipTOC heading (script captions, Add-AtlassianObjectScripts) the same
|
||||
# 'section-N' as the next listed heading: two anchor macros with one name,
|
||||
# so the TOC entry for the policy jumped to the script caption above it.
|
||||
if ($ToT) {
|
||||
$script:atlTotCount++
|
||||
$sectionAnchor = "table-$($script:atlTotCount)"
|
||||
}
|
||||
else {
|
||||
$script:atlSectionCount++
|
||||
$sectionAnchor = "section-$($script:atlSectionCount)"
|
||||
}
|
||||
|
||||
# id= is kept even though Confluence discards it: consumers parse the
|
||||
# generated file (before upload) and read the anchor from it, so it must
|
||||
# stay byte-identical to the anchor macro name.
|
||||
$anchorMacro = Get-AtlassianAnchorMacro $sectionAnchor
|
||||
[void]$script:atlBody.AppendLine("<h$Level id='$sectionAnchor'>$anchorMacro$(Get-AtlassianXmlText $HeaderText)</h$Level>")
|
||||
$fileName = $script:atlCurrentItemFileName
|
||||
}
|
||||
else {
|
||||
$sectionAnchor = $null
|
||||
$fileName = $null
|
||||
}
|
||||
|
||||
if ($ToT) {
|
||||
$script:atlTotAnchors += [PSCustomObject]@{
|
||||
Name = $HeaderText; Anchor = $sectionAnchor; Level = $Level; FileName = $fileName
|
||||
}
|
||||
}
|
||||
elseif (-not $SkipTOC) {
|
||||
$script:atlSectionAnchors += [PSCustomObject]@{
|
||||
Name = $HeaderText; Anchor = $sectionAnchor; Level = $Level; FileName = $fileName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Render the per-run table of contents into $Content.
|
||||
#
|
||||
# Each entry links the anchor its heading actually emitted. Deriving the target
|
||||
# from the heading text instead - which this did - is unreliable twice over:
|
||||
# duplicate policy names all resolved to the first occurrence (Confluence
|
||||
# disambiguates its own text-derived anchors with .1/.2, which a naive
|
||||
# derivation cannot reproduce), and every character other than a plain space
|
||||
# survived into the fragment, including '.', '(', ')', ':' and U+00A0.
|
||||
#
|
||||
# Confluence still generates its text-derived anchors, so externally saved
|
||||
# '#Policy-Name' links keep working; only the TOC moves to the reliable form.
|
||||
function Add-AtlassianTableOfContents {
|
||||
param(
|
||||
[System.Text.StringBuilder]$Content,
|
||||
[int]$MaxLevel = 4
|
||||
)
|
||||
|
||||
foreach ($header in $script:atlSectionAnchors) {
|
||||
if ($MaxLevel -gt 0 -and $header.Level -gt $MaxLevel) { continue }
|
||||
# Nest visually via non-breaking-space padding - Confluence doesn't honour
|
||||
# CSS anchor-level classes on imported storage-format content. Use the
|
||||
# numeric reference   (not the HTML entity ): storage format is
|
||||
# strict XML and only declares the five XML built-in entities.
|
||||
$indent = ""
|
||||
for ($i = 2; $i -lt $header.Level; $i++) { $indent += "  " }
|
||||
|
||||
$label = Get-AtlassianXmlText $header.Name
|
||||
if ($header.Anchor) {
|
||||
$href = Get-AtlassianHref $header.FileName $header.Anchor
|
||||
[void]$Content.AppendLine("$indent<a href='$href'>$label</a>")
|
||||
}
|
||||
else {
|
||||
# Registered while no body was open (a group/type header in 'Object'
|
||||
# mode), so the heading exists in no file and has no anchor. A
|
||||
# '#'-only href would jump to the top of the page instead; emit the
|
||||
# label as plain text.
|
||||
[void]$Content.AppendLine("$indent$label")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Add-AtlassianTableItems {
|
||||
param(
|
||||
$PolicyObject,
|
||||
[string]$TypeTitle,
|
||||
$Items,
|
||||
[string[]]$Properties,
|
||||
[string]$LngId,
|
||||
[switch]$AddCategories,
|
||||
[switch]$AddSubcategories,
|
||||
$CaptionOverride
|
||||
)
|
||||
|
||||
if ($CaptionOverride) {
|
||||
$caption = $CaptionOverride
|
||||
}
|
||||
elseif ($LngId -and $PolicyObject) {
|
||||
$caption = "$((Get-LanguageString $LngId)) - $(Get-DocCaptionName $PolicyObject)"
|
||||
}
|
||||
elseif ($PolicyObject) {
|
||||
$caption = "$(Get-DocCaptionName $PolicyObject) ($TypeTitle)"
|
||||
}
|
||||
else {
|
||||
$caption = $TypeTitle
|
||||
}
|
||||
|
||||
$table = [System.Text.StringBuilder]::new()
|
||||
[void]$table.AppendLine("<table>")
|
||||
[void]$table.AppendLine("<tr>")
|
||||
|
||||
$columnCount = 0
|
||||
foreach ($prop in $Properties) {
|
||||
[void]$table.AppendLine("<th>$((Invoke-DocTranslateColumnHeader $prop.Split('.')[-1]))</th>")
|
||||
$columnCount++
|
||||
}
|
||||
[void]$table.AppendLine("</tr>")
|
||||
|
||||
$curCategory = ""
|
||||
$curSubCategory = ""
|
||||
|
||||
foreach ($itemObj in $Items) {
|
||||
if ($itemObj.Category -and $curCategory -ne $itemObj.Category -and $AddCategories) {
|
||||
[void]$table.AppendLine("<tr><td colspan='$columnCount'><strong>$($itemObj.Category)</strong></td></tr>")
|
||||
$curCategory = $itemObj.Category
|
||||
$curSubCategory = ""
|
||||
}
|
||||
|
||||
if ($itemObj.SubCategory -and $curSubCategory -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
[void]$table.AppendLine("<tr><td colspan='$columnCount'><em>$($itemObj.SubCategory)</em></td></tr>")
|
||||
$curSubCategory = $itemObj.SubCategory
|
||||
}
|
||||
|
||||
try {
|
||||
[void]$table.AppendLine("<tr>")
|
||||
|
||||
$curCol = 0
|
||||
foreach ($prop in $Properties) {
|
||||
$curCol++
|
||||
try {
|
||||
$propArr = $prop.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
|
||||
if ($propName -eq "Value" -and ($itemObj.FullValueTable | Measure-Object).Count -gt 0) {
|
||||
[void]$table.AppendLine("<td><table><tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$table.AppendLine("<th>$($colProp.Name)</th>")
|
||||
}
|
||||
[void]$table.AppendLine("</tr>")
|
||||
foreach ($rowVal in $itemObj.FullValueTable) {
|
||||
[void]$table.AppendLine("<tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$table.AppendLine("<td>$((Set-AtlassianText $rowVal."$($colProp.Name)"))</td>")
|
||||
}
|
||||
[void]$table.AppendLine("</tr>")
|
||||
}
|
||||
[void]$table.AppendLine("</table></td>")
|
||||
}
|
||||
else {
|
||||
$indent = ""
|
||||
if ($curCol -eq 1 -and $itemObj.Level) {
|
||||
try {
|
||||
# One indent unit per nesting level (Level 1 = first
|
||||
# indent), matching the HTML/MD/Word providers. Was
|
||||
# off-by-one ($i started at 1), so Level-1 children
|
||||
# rendered flush. Negative levels produce no indent.
|
||||
$level = [int]$itemObj.Level
|
||||
#   (numeric non-breaking space) not —
|
||||
# Confluence storage format is strict XML and only
|
||||
# declares the five XML built-in entities, so
|
||||
# is dropped/rejected. Numeric refs always render.
|
||||
for ($i = 0; $i -lt $level; $i++) { $indent += "  " }
|
||||
} catch {}
|
||||
}
|
||||
[void]$table.AppendLine("<td>$($indent)$((Set-AtlassianText $tmpObj.$propName))</td>")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add property value for $prop" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to process property" 2
|
||||
}
|
||||
finally {
|
||||
[void]$table.AppendLine("</tr>")
|
||||
}
|
||||
}
|
||||
|
||||
[void]$table.AppendLine("</table>")
|
||||
[void]$script:atlBody.Append($table.ToString())
|
||||
# No -ToT: the caption is a plain level-6 heading, matching the HTML provider.
|
||||
# Passing -ToT here would add visible 'Table N. ' numbering (what the Markdown
|
||||
# provider does) and move the caption into $atlTotAnchors. That is a formatting
|
||||
# decision for the HTML and Atlassian outputs together, not a port detail.
|
||||
Add-AtlassianHeader $caption -Level 6
|
||||
}
|
||||
|
||||
# Confluence Storage Format text emitter. Escapes HTML special chars in plain
|
||||
# text; wraps XML-looking values in a `code` macro; wraps long text (>250
|
||||
# chars) in an `expand` macro with a first-line summary as the caption.
|
||||
function Set-AtlassianText {
|
||||
param([string]$Text, [switch]$NoCodeBlock)
|
||||
|
||||
if (-not $Text) { return }
|
||||
|
||||
$txtSummary = ""
|
||||
if ($Text.Length -gt 250) {
|
||||
$summaryMax = 40
|
||||
$idx = $Text.IndexOfAny(@("`r","`n"))
|
||||
if ($idx -gt 10 -and $idx -lt 50) { $summaryMax = $idx }
|
||||
$txtSummary = $Text.Substring(0, $summaryMax)
|
||||
}
|
||||
|
||||
$isCode = $false
|
||||
if (-not $NoCodeBlock) {
|
||||
$trim = $Text.Trim()
|
||||
if ($trim.StartsWith("<") -and $trim.EndsWith(">")) {
|
||||
$isCode = $true
|
||||
$Text = "<ac:structured-macro ac:name='code' ac:schema-version='1'>" +
|
||||
"<ac:parameter ac:name='language'>xml</ac:parameter>" +
|
||||
"<ac:plain-text-body><![CDATA[$Text]]></ac:plain-text-body>" +
|
||||
"</ac:structured-macro>"
|
||||
if ($txtSummary) {
|
||||
$txtSummary = $txtSummary.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $isCode) {
|
||||
$Text = $Text.Replace('&','&').Replace('<','<').Replace('>','>') #.Replace("`r`n",'<br />').Replace("`n",'<br />')
|
||||
}
|
||||
|
||||
if ($txtSummary) {
|
||||
"<ac:structured-macro ac:name='expand' ac:schema-version='1'>" +
|
||||
"<ac:parameter ac:name='title'>$txtSummary...</ac:parameter>" +
|
||||
"<ac:rich-text-body>$Text</ac:rich-text-body>" +
|
||||
"</ac:structured-macro>"
|
||||
}
|
||||
else {
|
||||
$Text
|
||||
}
|
||||
}
|
||||
|
||||
function Add-AtlassianObjectScripts {
|
||||
param($documentedObj)
|
||||
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent -or -not $scriptItem.Caption) { continue }
|
||||
[void]$script:atlBody.AppendLine("<ac:structured-macro ac:name='code' ac:schema-version='1'>")
|
||||
[void]$script:atlBody.AppendLine("<ac:parameter ac:name='language'>powershell</ac:parameter>")
|
||||
[void]$script:atlBody.AppendLine("<ac:plain-text-body><![CDATA[")
|
||||
[void]$script:atlBody.AppendLine($scriptItem.ScriptContent)
|
||||
[void]$script:atlBody.AppendLine("]]></ac:plain-text-body>")
|
||||
[void]$script:atlBody.AppendLine("</ac:structured-macro>")
|
||||
Add-AtlassianHeader $scriptItem.Caption -Level 6 -SkipTOC
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeAtlassianOutput
|
||||
@@ -0,0 +1,129 @@
|
||||
# CSV output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result (see DocumentationOutputJson.ps1
|
||||
# header for the field list). Writes one CSV file per object under
|
||||
# <RootFolder>[/<Organization>][/<ObjectType.Id>]/<ObjectName>.csv.
|
||||
#
|
||||
# Headless: explicit public options override persisted Documentation settings.
|
||||
# UI form construction belongs to the active UI backend; this file no longer
|
||||
# imports XAML at module load. See [[architecture-rules]] R1/R2.
|
||||
#
|
||||
# Settings consumed:
|
||||
# CSVExportProperties simple | extended | custom default 'simple'
|
||||
# CSVCustomDisplayProperties comma-separated property names default 'Name,Value,Category'
|
||||
# CSVDelimiter CSV delimiter character default '' (auto)
|
||||
# CSVDocumentationPath root folder for export default ''
|
||||
# CSVAddObjectType $true to nest under ObjectType default $true
|
||||
# CSVAddCompanyName $true to nest under Organization default $false
|
||||
|
||||
function Invoke-InitializeCSVOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "CSV"
|
||||
Value = "csv"
|
||||
# UI hints — where does this provider store its primary output path, and
|
||||
# is that path a folder (CSV writes many files) or a file?
|
||||
PrimaryPathOption = "CSVDocumentationPath"
|
||||
PathIsFolder = $true
|
||||
PreProcess = { Invoke-CSVPreProcessItems @args }
|
||||
Process = { Invoke-CSVProcessItem @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-CSVPreProcessItems {
|
||||
# No-op. Settings are the source of truth; the UI form (when wired) writes
|
||||
# them via Save-SettingStoreValue directly. Hook retained for symmetry / future use.
|
||||
}
|
||||
|
||||
function Invoke-CSVProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
$rootFolder = Get-DocumentationOutputOption csv "CSVDocumentationPath" ""
|
||||
$addObjectType = (Get-DocumentationOutputOption csv "CSVAddObjectType" $true) -eq $true
|
||||
$addCompanyName = (Get-DocumentationOutputOption csv "CSVAddCompanyName" $false) -eq $true
|
||||
$folder = Get-DocObjectFolder -RootFolder $rootFolder -PolicyType $PolicyObject.PolicyType -AddObjectType:$addObjectType -AddOrganization:$addCompanyName
|
||||
|
||||
$objName = $PolicyObject.Name
|
||||
|
||||
try {
|
||||
if (-not [IO.Directory]::Exists($folder)) {
|
||||
[IO.Directory]::CreateDirectory($folder) | Out-Null
|
||||
}
|
||||
|
||||
$mode = Get-DocumentationOutputOption csv "CSVExportProperties" "simple"
|
||||
$customProps = Get-DocumentationOutputOption csv "CSVCustomDisplayProperties" "Name,Value,Category"
|
||||
$delimiter = Get-DocumentationOutputOption csv "CSVDelimiter" ""
|
||||
|
||||
$csvParams = @{}
|
||||
if ($delimiter) { $csvParams['Delimiter'] = $delimiter }
|
||||
|
||||
$itemsToExport = @()
|
||||
|
||||
$useSectioned = ($mode -eq 'extended' -and $documentedObj.DisplayProperties) -or
|
||||
($mode -eq 'custom' -and $customProps)
|
||||
|
||||
if ($useSectioned) {
|
||||
if (($documentedObj.BasicInfo | Measure-Object).Count -gt 0) {
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Basic info"
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += $documentedObj.BasicInfo | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
if (($documentedObj.FilteredSettings | Measure-Object).Count -gt 0) {
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Settings"
|
||||
$itemsToExport += ""
|
||||
if ($mode -eq 'extended') {
|
||||
$displayProperties = $documentedObj.DisplayProperties
|
||||
}
|
||||
else {
|
||||
$displayProperties = $customProps.Split(",")
|
||||
}
|
||||
$itemsToExport += $documentedObj.FilteredSettings | Select-Object $displayProperties | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Applicability Rules"
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += $documentedObj.ApplicabilityRules | Select-Object Rule, Property, Value, Category | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Compliance Actions"
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += $documentedObj.ComplianceActions | Select-Object Action, Schedule, MessageTemplate, EmailCC, Category | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
if ($documentedObj.Assignments[0].RawIntent) { $properties = @("GroupMode","Group","Category","SubCategory") }
|
||||
elseif ($documentedObj.Assignments[0].Group) { $properties = @("GroupMode","Group","Category") }
|
||||
else { $properties = @("GroupMode","Groups","Category") }
|
||||
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Assignments"
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += $documentedObj.Assignments | Select-Object $properties | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
}
|
||||
else {
|
||||
$rows = @()
|
||||
$rows += $documentedObj.BasicInfo
|
||||
$rows += $documentedObj.FilteredSettings
|
||||
$itemsToExport = $rows | Select-Object Name, Value | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
$safeName = Remove-InvalidFileNameChars $objName
|
||||
$fileName = Join-Path $folder "$safeName.csv"
|
||||
Write-Log "Save documentation to $fileName"
|
||||
$itemsToExport | Out-File -LiteralPath $fileName -Encoding utf8 -Force
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to save CSV file for $objName in $folder" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeCSVOutput
|
||||
@@ -0,0 +1,476 @@
|
||||
# HTML output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result (see DocumentationOutputJson.ps1
|
||||
# header for the field list). Renders a single HTML document (or one file per
|
||||
# object) with CSS from Internal/Documentation/Assets/DefaultHTMLStyle.css.
|
||||
#
|
||||
# Differences vs old DocumentationHTML.psm1:
|
||||
# - Drops V1 NewObjectGroup/NewObjectType hooks (V2 string-arg is registered)
|
||||
# - Drops extended/custom property selectors that read $global:txt*/$global:cb*
|
||||
# UI controls. Always uses DefaultDocumentationProperties or ('Name','Value').
|
||||
# Phase 2 [DocumentationContext] will reintroduce these via $ctx.Options.
|
||||
# - Uses the typed-object API: $PolicyObject.Name + $PolicyObject.PolicyType.Title
|
||||
# - Invoke-HTMLProcessAllObjects (ScopeTags consolidated table) is stubbed
|
||||
# because Get-TableObjects doesn't exist yet — wire up in phase 2.
|
||||
|
||||
function Invoke-InitializeHTMLOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "HTML"
|
||||
Value = "html"
|
||||
# Path metadata for the bulk-doc UI: which option key stores the
|
||||
# primary output path, and whether that path is a folder or a file.
|
||||
# File-picker button wiring for the UI is declared separately in
|
||||
# UI/<backend>/ClassExtensions/DocumentationOutputHTMLUIExtension.ps1
|
||||
# to keep XAML control names + toolkit-specific filter strings out
|
||||
# of this pure-logic file.
|
||||
PrimaryPathOption = "HTMLDocumentName"
|
||||
PathIsFolder = $false
|
||||
PreProcess = { Invoke-HTMLPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-HTMLNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-HTMLNewObjectType @args }
|
||||
Process = { Invoke-HTMLProcessItem @args }
|
||||
PostProcess = { Invoke-HTMLPostProcessItems @args }
|
||||
ProcessAllObjects = { Invoke-HTMLProcessAllObjects @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-HTMLPreProcessItems {
|
||||
$script:sectionAnchors = @()
|
||||
$script:totAnchors = @()
|
||||
$script:htmlStrings = $null
|
||||
$script:currentItemFileName = $null
|
||||
|
||||
$defaultCSSFile = [IO.Path]::Combine($script:AppRootFolder, "Internal", "Documentation", "Assets", "DefaultHTMLStyle.css")
|
||||
$htmlCssFile = Get-DocumentationOutputOption html "HTMLCSSFile" $defaultCSSFile
|
||||
|
||||
if (-not $htmlCssFile) {
|
||||
Write-Log "CSS file not specified. Using default" 2
|
||||
$htmlCssFile = $defaultCSSFile
|
||||
}
|
||||
elseif (-not [IO.File]::Exists($htmlCssFile)) {
|
||||
Write-Log "CSS file $htmlCssFile not found. Using default" 2
|
||||
$htmlCssFile = $defaultCSSFile
|
||||
}
|
||||
|
||||
if ([IO.File]::Exists($htmlCssFile)) {
|
||||
Write-Log "Using CSS file $htmlCssFile"
|
||||
$script:cssStyle = ([IO.File]::ReadAllText($htmlCssFile)) + [Environment]::NewLine
|
||||
}
|
||||
else {
|
||||
Write-Log "CSS file $htmlCssFile not found. No styles applied" 2
|
||||
$script:cssStyle = ""
|
||||
}
|
||||
|
||||
$fileName = Get-DocumentationOutputOption html "HTMLDocumentName" ""
|
||||
if (-not $fileName) { $fileName = "%MyDocuments%\%Organization%-%Date%.html" }
|
||||
$fileName = Expand-FileName $fileName
|
||||
|
||||
$script:outFile = $fileName
|
||||
$script:documentPath = [IO.Path]::GetDirectoryName($fileName)
|
||||
$script:outputType = Get-DocumentationOutputOption html "HTMLDocumentFileType" "Full"
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
Write-Log "Document one file for each object + index file"
|
||||
}
|
||||
else {
|
||||
Write-Log "Document one single file for all objects"
|
||||
$script:outputType = "Full"
|
||||
$script:htmlStrings = [System.Text.StringBuilder]::new()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-HTMLPostProcessItems {
|
||||
$userName = $null
|
||||
$mail = ""
|
||||
$me = Get-CurrentUser
|
||||
if ($me) {
|
||||
if ($me.givenName -and $me.surname) {
|
||||
$userName = "$($me.givenName) $($me.surname)"
|
||||
}
|
||||
else {
|
||||
$userName = $me.displayName
|
||||
}
|
||||
if ($me.mail) { $mail = " ($($me.mail))" }
|
||||
}
|
||||
|
||||
$orgName = Get-CurrentOrganizationName
|
||||
|
||||
$title = Get-DocumentationOutputOption html "HTMLTitleProperty" "Intune documentation"
|
||||
if (-not $title) { $title = "Intune documentation" }
|
||||
|
||||
$htmlContent = [System.Text.StringBuilder]::new()
|
||||
[void]$htmlContent.AppendLine("<HTML>")
|
||||
[void]$htmlContent.AppendLine($script:cssStyle)
|
||||
[void]$htmlContent.AppendLine("<H1 class='header-level1'>$title</H1>")
|
||||
|
||||
if (-not ((Get-DocumentationOption "SkipDocumentInfo" $false) -eq $true)) {
|
||||
if ($orgName) { [void]$htmlContent.AppendLine("Organization: $orgName<br />") }
|
||||
if ($userName) { [void]$htmlContent.AppendLine("Generated by: $userName$mail<br />") }
|
||||
[void]$htmlContent.AppendLine("Generated: $((Get-Date).ToShortDateString()) $((Get-Date).ToLongTimeString())<br />")
|
||||
}
|
||||
|
||||
if ($script:sectionAnchors.Count -gt 0) {
|
||||
[void]$htmlContent.AppendLine("<br />")
|
||||
[void]$htmlContent.AppendLine("<H2 class='header-level2'>Table of Contents</H2>")
|
||||
}
|
||||
|
||||
$tocMaxLevel = 4
|
||||
foreach ($header in $script:sectionAnchors) {
|
||||
if ($tocMaxLevel -gt 0 -and $header.Level -gt $tocMaxLevel) { continue }
|
||||
[void]$htmlContent.AppendLine("<a href='$($header.FileName)#$($header.Anchor)' class='anchor-style anchor-level$($header.Level)'>$($header.Name)</a><br />")
|
||||
}
|
||||
if ($script:sectionAnchors.Count -gt 0) { [void]$htmlContent.AppendLine("<br />") }
|
||||
|
||||
$htmlText = $htmlContent.ToString()
|
||||
if ($script:outputType -eq "Full" -and $script:htmlStrings) {
|
||||
$htmlText += $script:htmlStrings.ToString()
|
||||
}
|
||||
$htmlText += "</HTML>"
|
||||
|
||||
Save-DocumentationFile $htmlText $script:outFile -OpenFile:((Get-DocumentationOutputOption html "HTMLOpenFile" $true) -eq $true)
|
||||
}
|
||||
|
||||
function Invoke-HTMLNewObjectGroup {
|
||||
param($groupId)
|
||||
$script:objectHeaderLevel = 2
|
||||
Add-HTMLHeader (Get-DocObjectTypeString $groupId)
|
||||
}
|
||||
|
||||
function Invoke-HTMLNewObjectType {
|
||||
param($objectTypeName)
|
||||
$script:objectHeaderLevel = 3
|
||||
Add-HTMLHeader $objectTypeName
|
||||
$script:objectHeaderLevel = 4
|
||||
}
|
||||
|
||||
function Invoke-HTMLProcessAllObjects {
|
||||
param($documentationInfo)
|
||||
# ScopeTags consolidated table is deferred — Get-TableObjects helper lands
|
||||
# in phase 2 alongside the engine. For now this is a no-op.
|
||||
}
|
||||
|
||||
function Invoke-HTMLProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
# A documented object may ask to be titled by something other than its display
|
||||
# name (see Get-DocumentationDisplayName). Headings and captions follow it; the
|
||||
# file name below deliberately does not.
|
||||
$objName = Get-DocumentationDisplayName $PolicyObject $documentedObj
|
||||
$script:docDisplayName = $objName
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
$script:totAnchors = @()
|
||||
$script:htmlStrings = [System.Text.StringBuilder]::new()
|
||||
$script:currentItemFileName = Get-HTMLObjectFileName $PolicyObject
|
||||
}
|
||||
|
||||
Add-HTMLHeader $objName
|
||||
[void]$script:htmlStrings.AppendLine("<br />")
|
||||
|
||||
try {
|
||||
foreach ($tableType in @("BasicInfo","FilteredSettings")) {
|
||||
if ($tableType -eq "BasicInfo") {
|
||||
$properties = @("Name","Value")
|
||||
$lngId = "SettingDetails.basics"
|
||||
}
|
||||
else {
|
||||
if ($documentedObj.DefaultDocumentationProperties) {
|
||||
$properties = $documentedObj.DefaultDocumentationProperties
|
||||
}
|
||||
else {
|
||||
$properties = @("Name","Value")
|
||||
}
|
||||
$lngId = "TableHeaders.settings"
|
||||
}
|
||||
|
||||
# Custom tables with a negative Order belong ABOVE the settings
|
||||
# table: the portal shows a MAM app config's "Settings catalog"
|
||||
# blade above its "Settings" blade.
|
||||
if ($tableType -eq "FilteredSettings") {
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -lt 0 } | Sort-Object -Property Order)) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.$tableType | Measure-Object).Count -gt 0) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $documentedObj.$tableType $properties $lngId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $documentedObj.ComplianceActions @("Action","Schedule","MessageTemplate","EmailCC") "Category.complianceActionsLabel"
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $documentedObj.ApplicabilityRules @("Rule","Property","Value") "SettingDetails.applicabilityRules"
|
||||
}
|
||||
|
||||
Add-HTMLObjectScripts $documentedObj
|
||||
|
||||
# Negative Order already rendered above the settings table.
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -ge 0 } | Sort-Object -Property Order)) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
if ($documentedObj.Assignments[0].RawIntent) {
|
||||
$properties = @("GroupMode","Group","Filter","FilterMode")
|
||||
$settingsObj = $documentedObj.Assignments | Where-Object { $_.Settings -ne $null } | Select-Object -First 1
|
||||
if ($settingsObj) {
|
||||
foreach ($objProp in $settingsObj.Settings.Keys) {
|
||||
if ($objProp -in $properties) { continue }
|
||||
if ($objProp -in @("Category","RawIntent")) { continue }
|
||||
$properties += "Settings.$objProp"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$hasFilter = $false
|
||||
foreach ($a in $documentedObj.Assignments) {
|
||||
if ($a.PSObject.Properties.Name -contains "FilterMode") { $hasFilter = $true; break }
|
||||
}
|
||||
$properties = @("Group")
|
||||
if ($hasFilter) { $properties += @("Filter","FilterMode") }
|
||||
}
|
||||
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $documentedObj.Assignments $properties "TableHeaders.assignments" -AddCategories
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
$perObject = [System.Text.StringBuilder]::new()
|
||||
[void]$perObject.AppendLine("<HTML>")
|
||||
[void]$perObject.AppendLine($script:cssStyle)
|
||||
$htmlText = $perObject.ToString() + $script:htmlStrings.ToString() + "</HTML>"
|
||||
$fileName = Join-Path $script:documentPath $script:currentItemFileName
|
||||
Save-DocumentationFile $htmlText $fileName
|
||||
$script:htmlStrings = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-HTMLObjectFileName {
|
||||
param($PolicyObject)
|
||||
|
||||
$objName = if ($PolicyObject.Name) { [string]$PolicyObject.Name } else { 'Unnamed policy' }
|
||||
$id = if ($PolicyObject.Id) { [string]$PolicyObject.Id } else { $null }
|
||||
$typeId = if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.Id) { [string]$PolicyObject.PolicyType.Id } else { $null }
|
||||
$suffix = if ($typeId -and $id) { " [$typeId-$id]" }
|
||||
elseif ($id) { " [$id]" }
|
||||
else { '' }
|
||||
return Remove-InvalidFileNameChars "$objName$suffix.html"
|
||||
}
|
||||
|
||||
function Add-HTMLHeader {
|
||||
param(
|
||||
[string]$HeaderText,
|
||||
[int]$Level = $script:objectHeaderLevel,
|
||||
[switch]$ToT,
|
||||
[switch]$SkipTOC
|
||||
)
|
||||
|
||||
if ($script:htmlStrings) {
|
||||
$prefix = ""
|
||||
if ($ToT) {
|
||||
$prefix = "Table $($script:totAnchors.Count + 1). "
|
||||
$sectionAnchor = "table-$($script:totAnchors.Count + 1)"
|
||||
}
|
||||
else {
|
||||
$sectionAnchor = "section-$($script:sectionAnchors.Count + 1)"
|
||||
}
|
||||
|
||||
[void]$script:htmlStrings.AppendLine("<H$Level id=`"$prefix$sectionAnchor`" class='header-level$Level'>$HeaderText</H$Level>")
|
||||
$fileName = $script:currentItemFileName
|
||||
}
|
||||
else {
|
||||
$sectionAnchor = $null
|
||||
$fileName = $null
|
||||
}
|
||||
|
||||
if ($ToT) {
|
||||
$script:totAnchors += [PSCustomObject]@{
|
||||
Name = $HeaderText; Anchor = $sectionAnchor; Level = $Level; FileName = $fileName
|
||||
}
|
||||
}
|
||||
elseif (-not $SkipTOC) {
|
||||
$script:sectionAnchors += [PSCustomObject]@{
|
||||
Name = $HeaderText; Anchor = $sectionAnchor; Level = $Level; FileName = $fileName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Add-HTMLTableItems {
|
||||
param(
|
||||
$PolicyObject,
|
||||
[string]$TypeTitle,
|
||||
$Items,
|
||||
[string[]]$Properties,
|
||||
[string]$LngId,
|
||||
[switch]$AddCategories,
|
||||
[switch]$AddSubcategories,
|
||||
$CaptionOverride
|
||||
)
|
||||
|
||||
if ($CaptionOverride) {
|
||||
$caption = $CaptionOverride
|
||||
}
|
||||
elseif ($LngId -and $PolicyObject) {
|
||||
$caption = "$((Get-LanguageString $LngId)) - $(Get-DocCaptionName $PolicyObject)"
|
||||
}
|
||||
elseif ($PolicyObject) {
|
||||
$caption = "$(Get-DocCaptionName $PolicyObject) ($TypeTitle)"
|
||||
}
|
||||
else {
|
||||
$caption = $TypeTitle
|
||||
}
|
||||
|
||||
$tableText = [System.Text.StringBuilder]::new()
|
||||
[void]$tableText.AppendLine("<table class='table-settings'>")
|
||||
[void]$tableText.AppendLine("<tr>")
|
||||
|
||||
$columnCount = 0
|
||||
foreach ($prop in $Properties) {
|
||||
[void]$tableText.AppendLine("<th>$((Invoke-DocTranslateColumnHeader $prop.Split('.')[-1]))</th>")
|
||||
$columnCount++
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
|
||||
$curCategory = ""
|
||||
$curSubCategory = ""
|
||||
$row = 1
|
||||
|
||||
foreach ($itemObj in $Items) {
|
||||
$additionalRowClass = ""
|
||||
|
||||
if ($itemObj.Category -and $curCategory -ne $itemObj.Category -and $AddCategories) {
|
||||
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level1'>$($itemObj.Category)</td></tr>")
|
||||
$curCategory = $itemObj.Category
|
||||
$curSubCategory = ""
|
||||
$row = 1
|
||||
}
|
||||
|
||||
if ($itemObj.SubCategory -and $curSubCategory -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level2'>$($itemObj.SubCategory)</td></tr>")
|
||||
$curSubCategory = $itemObj.SubCategory
|
||||
$row = 1
|
||||
}
|
||||
|
||||
if ($itemObj.PropertyIndex -is [int] -and $itemObj.PropertyIndex -eq 1) {
|
||||
$additionalRowClass = "row-new-property"
|
||||
}
|
||||
|
||||
try {
|
||||
$rowClass = if (($row % 2) -eq 1) { "row-odd" } else { "row-even" }
|
||||
$row++
|
||||
[void]$tableText.AppendLine("<tr class='$rowClass $additionalRowClass'>")
|
||||
|
||||
$curCol = 1
|
||||
foreach ($prop in $Properties) {
|
||||
try {
|
||||
$propArr = $prop.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
|
||||
if ($propName -eq "Value" -and ($itemObj.FullValueTable | Measure-Object).Count -gt 0) {
|
||||
[void]$tableText.AppendLine("<td><table class='table-value'><tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$tableText.AppendLine("<th>$($colProp.Name)</th>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
foreach ($rowVal in $itemObj.FullValueTable) {
|
||||
[void]$tableText.AppendLine("<tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$tableText.AppendLine("<td>$($rowVal."$($colProp.Name)")</td>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</table></td>")
|
||||
}
|
||||
else {
|
||||
$style = ""
|
||||
if ($curCol -eq 1 -and $itemObj.Level) {
|
||||
try {
|
||||
$level = [int]$itemObj.Level
|
||||
$style = " style='padding-left:$((5 + ($level * 5)))px;'"
|
||||
} catch {}
|
||||
}
|
||||
[void]$tableText.AppendLine("<td class='property-column$curCol'$style>$((Set-HTMLText $tmpObj.$propName))</td>")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add property value for $prop" $_.Exception
|
||||
}
|
||||
$curCol++
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to process property" 2
|
||||
}
|
||||
finally {
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
}
|
||||
}
|
||||
|
||||
[void]$tableText.AppendLine("</table>")
|
||||
[void]$script:htmlStrings.Append($tableText.ToString())
|
||||
Add-HTMLHeader $caption -Level 6
|
||||
}
|
||||
|
||||
function Set-HTMLText {
|
||||
param([string]$Text, [switch]$NoCodeBlock)
|
||||
|
||||
if (-not $Text) { return }
|
||||
|
||||
$txtSummary = ""
|
||||
if ($Text.Length -gt 250) {
|
||||
$summaryMax = 40
|
||||
$idx = $Text.IndexOfAny(@("`r","`n"))
|
||||
if ($idx -gt 10 -and $idx -lt 50) { $summaryMax = $idx }
|
||||
$txtSummary = $Text.Substring(0, $summaryMax)
|
||||
}
|
||||
|
||||
$code = $false
|
||||
if (-not $NoCodeBlock) {
|
||||
$trim = $Text.Trim()
|
||||
if ($trim.StartsWith("<") -and $trim.EndsWith(">")) {
|
||||
$code = $true
|
||||
$Text = "<pre class='code'>$($Text.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"'))</pre>"
|
||||
if ($txtSummary) {
|
||||
$txtSummary = $txtSummary.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $code) {
|
||||
$Text = $Text.Replace("`r`n", "<br />").Replace("`n", "<br />").Replace('&', '&')
|
||||
}
|
||||
|
||||
if ($txtSummary) {
|
||||
"<details class='description'><summary data-open='Minimize' data-close='$txtSummary...expand'></summary>$Text</details>"
|
||||
}
|
||||
else {
|
||||
$Text
|
||||
}
|
||||
}
|
||||
|
||||
function Add-HTMLObjectScripts {
|
||||
param($documentedObj)
|
||||
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent -or -not $scriptItem.Caption) { continue }
|
||||
[void]$script:htmlStrings.AppendLine("<pre class='code'>")
|
||||
[void]$script:htmlStrings.AppendLine($scriptItem.ScriptContent)
|
||||
[void]$script:htmlStrings.AppendLine("</pre>")
|
||||
Add-HTMLHeader $scriptItem.Caption -Level 6 -SkipTOC
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeHTMLOutput
|
||||
@@ -0,0 +1,214 @@
|
||||
# JSON output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result:
|
||||
# BasicInfo [{Name, Value}]
|
||||
# FilteredSettings [{Name, Value, Category?, SubCategory?}]
|
||||
# ComplianceActions [{Action, Schedule, MessageTemplate, EmailCC}]
|
||||
# ApplicabilityRules [{Rule, Property, Value}]
|
||||
# CustomTables [{Values[], Columns[], LanguageId?, Order}]
|
||||
# Assignments [{Group, GroupMode?, Filter?, FilterMode?, Settings?, RawIntent?}]
|
||||
# Scripts [{ScriptContent, Caption}] (pre-filtered by engine per options)
|
||||
|
||||
function Invoke-InitializeJsonOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "Json"
|
||||
Value = "json"
|
||||
# Path metadata (see DocumentationOutputHTML.ps1 header comment).
|
||||
# Json exposes no file-browse buttons on the bulk-doc form today;
|
||||
# add UI/<backend>/ClassExtensions/DocumentationOutputJsonUIExtension.ps1
|
||||
# if that changes.
|
||||
PrimaryPathOption = "JSONDocumentName"
|
||||
PathIsFolder = $false
|
||||
PreProcess = { Invoke-JsonPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-JsonNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-JsonNewObjectType @args }
|
||||
Process = { Invoke-JsonProcessItem @args }
|
||||
PostProcess = { Invoke-JsonPostProcessItems @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-JsonPreProcessItems {
|
||||
$script:jsonAllObjects = [System.Collections.Generic.List[object]]::new()
|
||||
$script:jsonCurrentTypeObjects = [System.Collections.Generic.List[object]]::new()
|
||||
$script:jsonCurrentTypeName = $null
|
||||
|
||||
$script:jsonOutputType = Get-DocumentationOutputOption json "JSONOutputFileType" "Full"
|
||||
|
||||
$jsonFileName = Get-DocumentationOutputOption json "JSONDocumentName" ""
|
||||
if (-not $jsonFileName) { $jsonFileName = "%MyDocuments%\%Organization%-%Date%.json" }
|
||||
|
||||
$script:jsonOutFile = Expand-FileName $jsonFileName
|
||||
$script:jsonDocumentPath = [IO.Path]::GetDirectoryName($script:jsonOutFile)
|
||||
}
|
||||
|
||||
function Invoke-JsonNewObjectGroup {
|
||||
param($groupId)
|
||||
# Groups are not used in flat JSON output
|
||||
}
|
||||
|
||||
function Invoke-JsonNewObjectType {
|
||||
param($objectTypeName)
|
||||
|
||||
if ($script:jsonOutputType -eq "ObjectType" -and
|
||||
$script:jsonCurrentTypeName -and
|
||||
$script:jsonCurrentTypeObjects.Count -gt 0) {
|
||||
Save-JsonTypeFile $script:jsonCurrentTypeName $script:jsonCurrentTypeObjects
|
||||
}
|
||||
|
||||
$script:jsonCurrentTypeName = $objectTypeName
|
||||
$script:jsonCurrentTypeObjects = [System.Collections.Generic.List[object]]::new()
|
||||
}
|
||||
|
||||
function Invoke-JsonProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
$objName = $PolicyObject.Name
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
try {
|
||||
$jsonObj = [ordered]@{
|
||||
objectType = $typeTitle
|
||||
name = $objName
|
||||
}
|
||||
|
||||
if (($documentedObj.BasicInfo | Measure-Object).Count -gt 0) {
|
||||
$basicInfo = [ordered]@{}
|
||||
foreach ($item in $documentedObj.BasicInfo) {
|
||||
if ($item.Name) { $basicInfo[$item.Name] = $item.Value }
|
||||
}
|
||||
$jsonObj.basicInfo = $basicInfo
|
||||
}
|
||||
|
||||
if (($documentedObj.FilteredSettings | Measure-Object).Count -gt 0) {
|
||||
$settings = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($item in $documentedObj.FilteredSettings) {
|
||||
$setting = [ordered]@{ name = $item.Name; value = $item.Value }
|
||||
if ($item.Category) { $setting.category = $item.Category }
|
||||
if ($item.SubCategory) { $setting.subCategory = $item.SubCategory }
|
||||
if ($item.PSObject.Properties['Level'] -and $item.Level) { $setting.level = $item.Level }
|
||||
$settings.Add($setting)
|
||||
}
|
||||
$jsonObj.settings = $settings
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
$actions = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($item in $documentedObj.ComplianceActions) {
|
||||
$actions.Add([ordered]@{
|
||||
action = $item.Action
|
||||
schedule = $item.Schedule
|
||||
messageTemplate = $item.MessageTemplate
|
||||
emailCC = $item.EmailCC
|
||||
})
|
||||
}
|
||||
$jsonObj.complianceActions = $actions
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
$rules = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($item in $documentedObj.ApplicabilityRules) {
|
||||
$rules.Add([ordered]@{
|
||||
rule = $item.Rule
|
||||
property = $item.Property
|
||||
value = $item.Value
|
||||
})
|
||||
}
|
||||
$jsonObj.applicabilityRules = $rules
|
||||
}
|
||||
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Sort-Object -Property Order)) {
|
||||
if (-not $customTable.Values -or ($customTable.Values | Measure-Object).Count -eq 0) { continue }
|
||||
|
||||
$tableKey = if ($customTable.LanguageId) { $customTable.LanguageId.Split('.')[-1] } else { "customTable" }
|
||||
$tableArr = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
foreach ($item in $customTable.Values) {
|
||||
$tableObj = [ordered]@{}
|
||||
foreach ($col in $customTable.Columns) {
|
||||
$colName = $col.Split('.')[-1]
|
||||
$tableObj[$colName] = "$($item.$colName)"
|
||||
}
|
||||
$tableArr.Add($tableObj)
|
||||
}
|
||||
$jsonObj[$tableKey] = $tableArr
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
$assignments = [System.Collections.Generic.List[object]]::new()
|
||||
$hasRawIntent = $null -ne $documentedObj.Assignments[0].RawIntent
|
||||
|
||||
foreach ($item in $documentedObj.Assignments) {
|
||||
if ($hasRawIntent) {
|
||||
$assignObj = [ordered]@{
|
||||
groupMode = $item.GroupMode
|
||||
group = $item.Group
|
||||
}
|
||||
if ($null -ne $item.Filter) { $assignObj.filter = $item.Filter }
|
||||
if ($null -ne $item.FilterMode) { $assignObj.filterMode = $item.FilterMode }
|
||||
if ($item.Settings) {
|
||||
$settingsObj = [ordered]@{}
|
||||
foreach ($key in $item.Settings.Keys) {
|
||||
if ($key -in @("Category","RawIntent")) { continue }
|
||||
$settingsObj[$key] = $item.Settings[$key]
|
||||
}
|
||||
$assignObj.settings = $settingsObj
|
||||
}
|
||||
}
|
||||
else {
|
||||
$assignObj = [ordered]@{ group = $item.Group }
|
||||
if ($item.PSObject.Properties.Name -contains "Filter") { $assignObj.filter = $item.Filter }
|
||||
if ($item.PSObject.Properties.Name -contains "FilterMode") { $assignObj.filterMode = $item.FilterMode }
|
||||
}
|
||||
$assignments.Add($assignObj)
|
||||
}
|
||||
$jsonObj.assignments = $assignments
|
||||
}
|
||||
|
||||
if (($documentedObj.Scripts | Measure-Object).Count -gt 0) {
|
||||
$scripts = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent) { continue }
|
||||
$scripts.Add([ordered]@{
|
||||
caption = $scriptItem.Caption
|
||||
content = $scriptItem.ScriptContent
|
||||
})
|
||||
}
|
||||
if ($scripts.Count -gt 0) { $jsonObj.scripts = $scripts }
|
||||
}
|
||||
|
||||
$script:jsonCurrentTypeObjects.Add($jsonObj)
|
||||
if ($script:jsonOutputType -ne "ObjectType") { $script:jsonAllObjects.Add($jsonObj) }
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-JsonPostProcessItems {
|
||||
$openFile = (Get-DocumentationOutputOption json "JSONOpenFile" $true) -eq $true
|
||||
|
||||
if ($script:jsonOutputType -eq "ObjectType") {
|
||||
if ($script:jsonCurrentTypeName -and $script:jsonCurrentTypeObjects.Count -gt 0) {
|
||||
Save-JsonTypeFile $script:jsonCurrentTypeName $script:jsonCurrentTypeObjects
|
||||
}
|
||||
Write-Log "Json documentation saved to folder: $($script:jsonDocumentPath)"
|
||||
}
|
||||
else {
|
||||
$jsonContent = ConvertTo-Json -InputObject @($script:jsonAllObjects) -Depth 20
|
||||
Save-DocumentationFile $jsonContent $script:jsonOutFile -OpenFile:$openFile
|
||||
}
|
||||
}
|
||||
|
||||
function Save-JsonTypeFile {
|
||||
param($typeName, $objects)
|
||||
|
||||
$safeTypeName = Remove-InvalidFileNameChars ($typeName.Replace(" ", "_"))
|
||||
$typeFileName = [IO.Path]::Combine($script:jsonDocumentPath, "$safeTypeName.json")
|
||||
$jsonContent = ConvertTo-Json -InputObject @($objects) -Depth 20
|
||||
Save-DocumentationFile $jsonContent $typeFileName
|
||||
Write-Log "Saved $($objects.Count) objects to $typeFileName"
|
||||
}
|
||||
|
||||
Invoke-InitializeJsonOutput
|
||||
@@ -0,0 +1,497 @@
|
||||
# Markdown output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result (see DocumentationOutputJson.ps1
|
||||
# header for the field list). Renders HTML-tabled markdown with CSS styling
|
||||
# from Internal/Documentation/Assets/DefaultMDStyle.css (or a user-supplied .css file).
|
||||
#
|
||||
# Differences vs old DocumentationMD.psm1:
|
||||
# - Drops V1 NewObjectGroup/NewObjectType hooks; V2 (string-arg) is registered
|
||||
# - Drops the extended/custom property selectors that read $global:cb*/$global:txt*
|
||||
# UI controls. Always uses DefaultDocumentationProperties or ('Name','Value').
|
||||
# Phase 2 [DocumentationContext] will reintroduce these via $ctx.Options.
|
||||
# - Drops the unused commented-out block at end of Invoke-MDPostProcessItems
|
||||
# - Uses the typed-object API: $PolicyObject.Name + $PolicyObject.PolicyType.Title
|
||||
# instead of Get-GraphObjectName $obj $objectType + $objectType.Title
|
||||
|
||||
function Invoke-InitializeMDOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "Markdown"
|
||||
Value = "md"
|
||||
# Path metadata (see DocumentationOutputHTML.ps1 header comment).
|
||||
# UI browse-button wiring lives in
|
||||
# UI/<backend>/ClassExtensions/DocumentationOutputMDUIExtension.ps1.
|
||||
PrimaryPathOption = "MDDocumentName"
|
||||
PathIsFolder = $false
|
||||
PreProcess = { Invoke-MDPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-MDNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-MDNewObjectType @args }
|
||||
Process = { Invoke-MDProcessItem @args }
|
||||
PostProcess = { Invoke-MDPostProcessItems @args }
|
||||
ProcessAllObjects = { Invoke-MDProcessAllObjects @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-MDProcessAllObjects {
|
||||
param($allObjectTypeObjects, $objectType)
|
||||
# Reserved for cross-object aggregation (e.g. consolidated ScopeTags table)
|
||||
}
|
||||
|
||||
function Invoke-MDPreProcessItems {
|
||||
$script:sectionAnchors = @()
|
||||
$script:totAnchors = @()
|
||||
$script:mdStrings = $null
|
||||
$script:currentItemFileName = $null
|
||||
|
||||
$defaultCSSFile = [IO.Path]::Combine($script:AppRootFolder, "Internal", "Documentation", "Assets", "DefaultMDStyle.css")
|
||||
$mdCssFile = Get-DocumentationOutputOption md "MDCSSFile" $defaultCSSFile
|
||||
$includeCss = (Get-DocumentationOutputOption md "MDIncludeCSS" $true) -eq $true
|
||||
|
||||
if (-not $mdCssFile) {
|
||||
Write-Log "CSS file not specified. Using default" 2
|
||||
$mdCssFile = $defaultCSSFile
|
||||
}
|
||||
elseif (-not [IO.File]::Exists($mdCssFile)) {
|
||||
Write-Log "CSS file $mdCssFile not found. Using default" 2
|
||||
$mdCssFile = $defaultCSSFile
|
||||
}
|
||||
|
||||
if ($includeCss -and [IO.File]::Exists($mdCssFile)) {
|
||||
Write-Log "Using CSS file $mdCssFile"
|
||||
$script:cssStyle = ([IO.File]::ReadAllText($mdCssFile)) + [Environment]::NewLine
|
||||
}
|
||||
else {
|
||||
Write-Log "CSS file $mdCssFile not found. No styles applied" 2
|
||||
$script:cssStyle = ""
|
||||
}
|
||||
|
||||
$fileName = Get-DocumentationOutputOption md "MDDocumentName" ""
|
||||
if (-not $fileName) { $fileName = "%MyDocuments%\%Organization%-%Date%.md" }
|
||||
$fileName = Expand-FileName $fileName
|
||||
|
||||
$script:outFile = $fileName
|
||||
$script:documentPath = [IO.Path]::GetDirectoryName($fileName)
|
||||
$script:outputType = Get-DocumentationOutputOption md "MDDocumentFileType" "Full"
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
Write-Log "Document one file for each object + index file"
|
||||
}
|
||||
else {
|
||||
Write-Log "Document one single file for all objects"
|
||||
$script:outputType = "Full"
|
||||
$script:mdStrings = [System.Text.StringBuilder]::new()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-MDPostProcessItems {
|
||||
$userName = $null
|
||||
$mail = ""
|
||||
$me = Get-CurrentUser
|
||||
if ($me) {
|
||||
if ($me.givenName -and $me.surname) {
|
||||
$userName = "$($me.givenName) $($me.surname)"
|
||||
}
|
||||
else {
|
||||
$userName = $me.displayName
|
||||
}
|
||||
if ($me.mail) { $mail = " ($($me.mail))" }
|
||||
}
|
||||
|
||||
$orgName = Get-CurrentOrganizationName
|
||||
|
||||
$title = Get-DocumentationOutputOption md "MDTitleProperty" "Intune documentation"
|
||||
if (-not $title) { $title = "Intune documentation" }
|
||||
|
||||
$mdContent = [System.Text.StringBuilder]::new()
|
||||
[void]$mdContent.AppendLine("# $title")
|
||||
[void]$mdContent.AppendLine("")
|
||||
[void]$mdContent.AppendLine("")
|
||||
|
||||
if (-not ((Get-DocumentationOption "SkipDocumentInfo" $false) -eq $true)) {
|
||||
if ($orgName) { [void]$mdContent.AppendLine("*Organization:* $orgName`n") }
|
||||
if ($userName) { [void]$mdContent.AppendLine("*Generated by:* $userName$mail`n") }
|
||||
|
||||
$skipDate = (Get-DocumentationOutputOption md "MDDocumentSkipDate" $false) -eq $true
|
||||
if (-not $skipDate) {
|
||||
[void]$mdContent.AppendLine("*Generated:* $((Get-Date).ToShortDateString()) $((Get-Date).ToLongTimeString())`n")
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:sectionAnchors.Count -gt 0) {
|
||||
[void]$mdContent.AppendLine("")
|
||||
[void]$mdContent.AppendLine("## Table of Contents")
|
||||
}
|
||||
|
||||
foreach ($header in $script:sectionAnchors) {
|
||||
$indent = [string]::new(" ", (($header.Level - 1) * 2))
|
||||
[void]$mdContent.AppendLine("$indent- [$($header.Name)]($($header.FileName)#$($header.Anchor))`n")
|
||||
}
|
||||
[void]$mdContent.AppendLine("")
|
||||
|
||||
$mdText = $script:cssStyle + $mdContent.ToString()
|
||||
if ($script:outputType -eq "Full" -and $script:mdStrings) {
|
||||
$mdText += $script:mdStrings.ToString()
|
||||
}
|
||||
|
||||
Save-DocumentationFile $mdText $script:outFile -OpenFile:((Get-DocumentationOutputOption md "MDOpenFile" $true) -eq $true)
|
||||
}
|
||||
|
||||
function Invoke-MDNewObjectGroup {
|
||||
param($groupId)
|
||||
Add-MDHeader (Get-DocObjectTypeString $groupId) -Level 1 -UseHTML
|
||||
}
|
||||
|
||||
function Invoke-MDNewObjectType {
|
||||
param($objectTypeName)
|
||||
Add-MDHeader $objectTypeName -Level 2 -UseHTML
|
||||
}
|
||||
|
||||
# Per-object file name for Object mode. Identity, not title: the policy's own
|
||||
# display name plus its type and id, the shape Get-HTMLObjectFileName uses.
|
||||
#
|
||||
# The display name alone was never unique. Five enrollment defaults - device
|
||||
# limit, platform restrictions, enrollment status page, Windows Hello for
|
||||
# Business, Windows Restore - are all called "All users and all devices", so
|
||||
# they all wrote All_users_and_all_devices.md and the last one won. Deriving the
|
||||
# name from the heading instead would not do either: the heading may be a
|
||||
# DocumentName override, and a file name has to identify the object, not
|
||||
# describe it. The type and id settle it. Spaces become underscores and the
|
||||
# suffix carries no brackets, because this name lands inside Markdown link
|
||||
# destinations.
|
||||
function Get-MDObjectFileName {
|
||||
param($PolicyObject)
|
||||
|
||||
$objName = if ($PolicyObject.Name) { [string]$PolicyObject.Name } else { 'Unnamed policy' }
|
||||
$id = if ($PolicyObject.Id) { [string]$PolicyObject.Id } else { $null }
|
||||
$typeId = if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.Id) { [string]$PolicyObject.PolicyType.Id } else { $null }
|
||||
$suffix = if ($typeId -and $id) { " $typeId-$id" }
|
||||
elseif ($id) { " $id" }
|
||||
else { '' }
|
||||
|
||||
# A policy name can run past 200 characters and the suffix adds up to
|
||||
# around 120 more - past what a path may hold, where the name-only file
|
||||
# still wrote. The suffix is the identity, so it is the name that gives way.
|
||||
$maxNameLength = 80
|
||||
if ($objName.Length -gt $maxNameLength) { $objName = $objName.Substring(0, $maxNameLength).TrimEnd() }
|
||||
|
||||
return (Remove-InvalidFileNameChars "$objName$suffix.md").Replace(' ', '_')
|
||||
}
|
||||
|
||||
function Invoke-MDProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
# A documented object may ask to be titled by something other than its display
|
||||
# name (see Get-DocumentationDisplayName). Headings and captions follow it; the
|
||||
# file name below deliberately does not.
|
||||
$objName = Get-DocumentationDisplayName $PolicyObject $documentedObj
|
||||
$script:docDisplayName = $objName
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
$script:totAnchors = @()
|
||||
$script:mdStrings = [System.Text.StringBuilder]::new()
|
||||
$script:currentItemFileName = "./$(Get-MDObjectFileName $PolicyObject)"
|
||||
}
|
||||
|
||||
Add-MDHeader $objName -Level 3 -UseHTML
|
||||
[void]$script:mdStrings.AppendLine("")
|
||||
|
||||
try {
|
||||
foreach ($tableType in @("BasicInfo","FilteredSettings")) {
|
||||
if ($tableType -eq "BasicInfo") {
|
||||
$properties = @("Name","Value")
|
||||
$lngId = "SettingDetails.basics"
|
||||
}
|
||||
else {
|
||||
if ($documentedObj.DefaultDocumentationProperties) {
|
||||
$properties = $documentedObj.DefaultDocumentationProperties
|
||||
}
|
||||
else {
|
||||
$properties = @("Name","Value")
|
||||
}
|
||||
$lngId = "TableHeaders.settings"
|
||||
}
|
||||
|
||||
# Custom tables with a negative Order belong ABOVE the settings
|
||||
# table: the portal shows a MAM app config's "Settings catalog"
|
||||
# blade above its "Settings" blade.
|
||||
if ($tableType -eq "FilteredSettings") {
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -lt 0 } | Sort-Object -Property Order)) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.$tableType | Measure-Object).Count -gt 0) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.$tableType $properties $lngId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.ComplianceActions @("Action","Schedule","MessageTemplate","EmailCC") "Category.complianceActionsLabel"
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.ApplicabilityRules @("Rule","Property","Value") "SettingDetails.applicabilityRules"
|
||||
}
|
||||
|
||||
Add-MDObjectScripts $documentedObj
|
||||
|
||||
# Negative Order already rendered above the settings table.
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -ge 0 } | Sort-Object -Property Order)) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
if ($documentedObj.Assignments[0].RawIntent) {
|
||||
$properties = @("GroupMode","Group","Filter","FilterMode")
|
||||
$settingsObj = $documentedObj.Assignments | Where-Object { $_.Settings -ne $null } | Select-Object -First 1
|
||||
if ($settingsObj) {
|
||||
foreach ($objProp in $settingsObj.Settings.Keys) {
|
||||
if ($objProp -in $properties) { continue }
|
||||
if ($objProp -in @("Category","RawIntent")) { continue }
|
||||
$properties += "Settings.$objProp"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$hasFilter = $false
|
||||
foreach ($a in $documentedObj.Assignments) {
|
||||
if ($a.PSObject.Properties.Name -contains "FilterMode") { $hasFilter = $true; break }
|
||||
}
|
||||
$properties = @("Group")
|
||||
if ($hasFilter) { $properties += @("Filter","FilterMode") }
|
||||
}
|
||||
|
||||
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.Assignments $properties "TableHeaders.assignments" -AddCategories
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
$perObjectText = $script:cssStyle + $script:mdStrings.ToString()
|
||||
$fileName = Join-Path $script:documentPath $script:currentItemFileName
|
||||
Save-DocumentationFile $perObjectText $fileName
|
||||
$script:mdStrings = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Add-MDTableItems {
|
||||
param(
|
||||
$PolicyObject,
|
||||
[string]$TypeTitle,
|
||||
$Items,
|
||||
[string[]]$Properties,
|
||||
[string]$LngId,
|
||||
[switch]$AddCategories,
|
||||
[switch]$AddSubcategories,
|
||||
$CaptionOverride
|
||||
)
|
||||
|
||||
$objName = Get-DocCaptionName $PolicyObject
|
||||
if ($CaptionOverride) {
|
||||
$caption = $CaptionOverride
|
||||
}
|
||||
elseif ($LngId) {
|
||||
$caption = "$((Get-LanguageString $LngId)) - $objName"
|
||||
}
|
||||
else {
|
||||
$caption = "$objName ($TypeTitle)"
|
||||
}
|
||||
|
||||
$tableText = [System.Text.StringBuilder]::new()
|
||||
[void]$tableText.AppendLine("<table class='table-settings'>")
|
||||
[void]$tableText.AppendLine("<tr class='table-header1'>")
|
||||
|
||||
$columnCount = 0
|
||||
foreach ($prop in $Properties) {
|
||||
[void]$tableText.AppendLine("<td>$((Invoke-DocTranslateColumnHeader $prop.Split('.')[-1]))</td>")
|
||||
$columnCount++
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
|
||||
$curCategory = ""
|
||||
$curSubCategory = ""
|
||||
|
||||
foreach ($itemObj in $Items) {
|
||||
$additionalRowClass = ""
|
||||
|
||||
if ($itemObj.Category -and $curCategory -ne $itemObj.Category -and $AddCategories) {
|
||||
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level1'>$((Set-MDText $itemObj.Category))</td></tr>")
|
||||
$curCategory = $itemObj.Category
|
||||
$curSubCategory = ""
|
||||
}
|
||||
|
||||
if ($itemObj.SubCategory -and $curSubCategory -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level2'>$((Set-MDText $itemObj.SubCategory))</td></tr>")
|
||||
$curSubCategory = $itemObj.SubCategory
|
||||
}
|
||||
|
||||
if ($itemObj.PropertyIndex -is [int] -and $itemObj.PropertyIndex -eq 1) {
|
||||
$additionalRowClass = "row-new-property"
|
||||
}
|
||||
|
||||
try {
|
||||
[void]$tableText.AppendLine("<tr class='$additionalRowClass'>")
|
||||
$curCol = 1
|
||||
foreach ($prop in $Properties) {
|
||||
try {
|
||||
$propArr = $prop.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
|
||||
if ($propName -eq "Value" -and ($itemObj.FullValueTable | Measure-Object).Count -gt 0) {
|
||||
[void]$tableText.AppendLine("<td><table class='table-value'><tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$tableText.AppendLine("<td class='table-header1'>$($colProp.Name)</td>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
foreach ($rowVal in $itemObj.FullValueTable) {
|
||||
[void]$tableText.AppendLine("<tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$tableText.AppendLine("<td>$($rowVal."$($colProp.Name)")</td>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</table></td>")
|
||||
}
|
||||
else {
|
||||
$style = ""
|
||||
if ($curCol -eq 1 -and $itemObj.Level) {
|
||||
try {
|
||||
$level = [int]$itemObj.Level
|
||||
$style = " style='padding-left:$((5 + ($level * 5)))px !important;'"
|
||||
} catch {}
|
||||
}
|
||||
[void]$tableText.AppendLine("<td class='property-column$curCol'$style>$((Set-MDText $tmpObj.$propName -CodeBlock))</td>")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add property value for $prop" $_.Exception
|
||||
}
|
||||
$curCol++
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to process property" 2
|
||||
}
|
||||
finally {
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
}
|
||||
}
|
||||
|
||||
[void]$tableText.AppendLine("</table>")
|
||||
Add-MDText $tableText.ToString()
|
||||
Add-MDHeader $caption -Level 6 -ToT -AddParagraph
|
||||
}
|
||||
|
||||
function Add-MDText {
|
||||
param([string]$Text, [switch]$AddParagraph)
|
||||
[void]$script:mdStrings.AppendLine($Text)
|
||||
if ($AddParagraph) { [void]$script:mdStrings.AppendLine("") }
|
||||
}
|
||||
|
||||
function Set-MDText {
|
||||
param([string]$Text, [switch]$CodeBlock)
|
||||
|
||||
if ($null -eq $Text) { return }
|
||||
|
||||
$txtSummary = ""
|
||||
$textOut = ""
|
||||
|
||||
if ($Text -and $Text.Length -gt 250) {
|
||||
$summaryMax = 40
|
||||
$idx = $Text.IndexOfAny(@("`r","`n"))
|
||||
if ($idx -gt 10 -and $idx -lt 50) { $summaryMax = $idx }
|
||||
$txtSummary = $Text.Substring(0, $summaryMax)
|
||||
}
|
||||
|
||||
if ($CodeBlock) {
|
||||
$trim = $Text.Trim()
|
||||
if ($trim.StartsWith("<?xml") -or $trim.StartsWith("<xml") -or ($trim.StartsWith("<") -and $trim.EndsWith(">"))) {
|
||||
$nl = [Environment]::NewLine
|
||||
$textOut = "$nl$nl``````xml$nl$Text$nl```````$nl$nl"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $CodeBlock -or -not $textOut) {
|
||||
$t = $Text.Replace("|", '`|')
|
||||
$t = $t.Replace("*", '`*')
|
||||
$t = $t.Replace("`$", '`$')
|
||||
$t = $t.Replace("`r`n", "<br />")
|
||||
$textOut = $t.Replace("`n", "<br />")
|
||||
}
|
||||
|
||||
if ($txtSummary) {
|
||||
"<details class='description'><summary data-open='Minimize' data-close='$txtSummary...expand'></summary>$textOut</details>"
|
||||
}
|
||||
else {
|
||||
$textOut
|
||||
}
|
||||
}
|
||||
|
||||
function Add-MDHeader {
|
||||
param(
|
||||
[string]$Text,
|
||||
[int]$Level = 1,
|
||||
[switch]$AddParagraph,
|
||||
[switch]$UseHTML,
|
||||
[switch]$ToT,
|
||||
[switch]$SkipTOC
|
||||
)
|
||||
|
||||
if ($script:mdStrings) {
|
||||
$prefix = ""
|
||||
if ($ToT) { $prefix = "Table $($script:totAnchors.Count + 1). " }
|
||||
|
||||
if ($UseHTML) {
|
||||
if ($ToT) { $sectionAnchor = "table-$($script:totAnchors.Count + 1)" }
|
||||
else { $sectionAnchor = "section-$($script:sectionAnchors.Count + 1)" }
|
||||
|
||||
[void]$script:mdStrings.AppendLine("<h$Level id=`"$prefix$sectionAnchor`">$Text</h$Level>")
|
||||
}
|
||||
else {
|
||||
$Text = "$prefix$Text"
|
||||
$sectionAnchor = $Text.ToLower().Replace(" ", "-").Replace("[","").Replace("]","")
|
||||
$mdHeader = [string]::new('#', $Level)
|
||||
[void]$script:mdStrings.AppendLine("$mdHeader $Text")
|
||||
}
|
||||
$fileName = $script:currentItemFileName
|
||||
}
|
||||
else {
|
||||
$sectionAnchor = $null
|
||||
$fileName = $null
|
||||
}
|
||||
|
||||
if ($ToT) {
|
||||
$script:totAnchors += [PSCustomObject]@{
|
||||
Name = $Text; Anchor = $sectionAnchor; FileName = $fileName; Level = $Level
|
||||
}
|
||||
}
|
||||
elseif (-not $SkipTOC) {
|
||||
$script:sectionAnchors += [PSCustomObject]@{
|
||||
Name = $Text; Anchor = $sectionAnchor; FileName = $fileName; Level = $Level
|
||||
}
|
||||
}
|
||||
|
||||
if ($AddParagraph) { [void]$script:mdStrings.AppendLine("`n") }
|
||||
}
|
||||
|
||||
function Add-MDObjectScripts {
|
||||
param($documentedObj)
|
||||
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent -or -not $scriptItem.Caption) { continue }
|
||||
[void]$script:mdStrings.AppendLine("~~~powershell")
|
||||
[void]$script:mdStrings.AppendLine($scriptItem.ScriptContent)
|
||||
[void]$script:mdStrings.AppendLine("~~~")
|
||||
Add-MDHeader $scriptItem.Caption -Level 6 -SkipTOC -AddParagraph
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeMDOutput
|
||||
@@ -0,0 +1,929 @@
|
||||
# Word output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result (see DocumentationOutputJson.ps1
|
||||
# header for the field list). Writes a .docx, .docm/.xml (strict), or .pdf via
|
||||
# Microsoft.Office.Interop.Word COM automation. Word must be installed locally.
|
||||
#
|
||||
# https://docs.microsoft.com/en-us/office/vba/api/overview/word
|
||||
#
|
||||
# Differences vs old DocumentationWord.psm1:
|
||||
# - Uses the typed-object API: $PolicyObject.Name + $PolicyObject.PolicyType
|
||||
# instead of Get-GraphObjectName / Get-ObjectTypeString taking $objectType
|
||||
# - V1 NewObjectGroup/NewObjectType ($obj-arg) replaced by V2 (string-arg);
|
||||
# the old V1 versions were unreachable (registration used V2)
|
||||
# - The "Attach raw object JSON" Word feature is stubbed out — it depends on
|
||||
# Export-GraphObject which lives in old MSGraph.psm1 and hasn't been ported
|
||||
# to the new project. A "feature unavailable" log message replaces it; phase 2
|
||||
# can re-enable once the equivalent exporter is wired up.
|
||||
# - Invoke-WordProcessAllObjects ScopeTags consolidated table is stubbed too,
|
||||
# same reason as the HTML provider (Get-TableObjects deferred to phase 2).
|
||||
# - All other COM logic — cover page, ToC, building blocks, style hashtable,
|
||||
# option snapshot/restore, save & close — preserved verbatim.
|
||||
|
||||
# Load the Word primary interop assembly. Deliberately NOT called at module
|
||||
# import: Add-Type -AssemblyName fails on PS7 (it resolves against the current
|
||||
# directory, not the GAC), so this always fell through to a recursive scan of
|
||||
# %windir%\assembly\GAC_MSIL - ~77ms on every single Import-Module, for a
|
||||
# feature most sessions never use. It also put an assembly in the AppDomain
|
||||
# whose GetExportedTypes() throws, which is one of the two things that used to
|
||||
# take down Avalonia's XAML loader (see Host.SanitizeXamlTypeSystem).
|
||||
#
|
||||
# Idempotent: returns $true as soon as the interop types are resolvable.
|
||||
#
|
||||
# The readiness probe is WdSaveFormat, not the Application coclass. Everything
|
||||
# this provider needs from the interop assembly is enums - the Word instance
|
||||
# itself comes from late-bound `New-Object -ComObject Word.Application` - and on
|
||||
# PS7 the enums resolve while the coclass does not. Probing Application would
|
||||
# therefore report "not loaded" forever on PS7, which is also why the previous
|
||||
# code's short-circuit never fired there and re-scanned the GAC on every import.
|
||||
function Initialize-WordInteropAssembly {
|
||||
if ("Microsoft.Office.Interop.Word.WdSaveFormat" -as [Type]) { return $true }
|
||||
|
||||
try {
|
||||
Add-Type -AssemblyName Microsoft.Office.Interop.Word -ErrorAction Stop
|
||||
if ("Microsoft.Office.Interop.Word.WdSaveFormat" -as [Type]) { return $true }
|
||||
}
|
||||
catch { }
|
||||
|
||||
try {
|
||||
$wordFile = Get-ChildItem -Path "$($env:windir)\assembly\GAC_MSIL" -Filter "Microsoft.Office.Interop.Word.dll" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($wordFile -and $wordFile.Exists) {
|
||||
Add-Type -Path $wordFile.FullName
|
||||
if ("Microsoft.Office.Interop.Word.WdSaveFormat" -as [Type]) { return $true }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add Word Interop type. Cannot create Word documents. Verify that Word is installed properly." $_.Exception
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-LogError "Word Interop type not found. Cannot create Word documents. Verify that Word is installed properly." $null
|
||||
return $false
|
||||
}
|
||||
|
||||
function Invoke-InitializeWordOutput {
|
||||
# Word output relies on Microsoft.Office.Interop.Word COM automation, which only
|
||||
# exists on Windows with Word installed. Skip the provider entirely elsewhere.
|
||||
if (-not $script:IsWindowsOS) {
|
||||
Write-Log "Word documentation output is not available on this platform (requires Windows + Word). Skipping."
|
||||
return
|
||||
}
|
||||
|
||||
# Registration stays gated on Word being installed, as before - but probed by
|
||||
# reading the COM registration straight out of the registry, which loads
|
||||
# nothing into the AppDomain. [Type]::GetTypeFromProgID looks like the natural
|
||||
# call here and is correct on PS7, but on PS5.1 the .NET Framework resolves a
|
||||
# ProgID to its primary interop assembly and loads it - which would reintroduce
|
||||
# exactly the eager load this is meant to remove, on the one shell where the
|
||||
# old code's short-circuit actually worked.
|
||||
#
|
||||
# The interop itself is loaded lazily by Invoke-WordActivate, i.e. only when a
|
||||
# documentation run actually selects Word output.
|
||||
$wordRegistered = (Test-Path 'HKLM:\SOFTWARE\Classes\Word.Application') -or
|
||||
(Test-Path 'HKCU:\SOFTWARE\Classes\Word.Application')
|
||||
if (-not $wordRegistered) {
|
||||
Write-Log "Word is not registered on this machine. Word documentation output will not be available." 2
|
||||
return
|
||||
}
|
||||
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "Word"
|
||||
Value = "word"
|
||||
# Path metadata (see DocumentationOutputHTML.ps1 header comment).
|
||||
# UI browse-button wiring lives in
|
||||
# UI/<backend>/ClassExtensions/DocumentationOutputWordUIExtension.ps1.
|
||||
PrimaryPathOption = "WordDocumentName"
|
||||
PathIsFolder = $false
|
||||
Activate = { Invoke-WordActivate @args }
|
||||
PreProcess = { Invoke-WordPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-WordNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-WordNewObjectType @args }
|
||||
Process = { Invoke-WordProcessItem @args }
|
||||
PostProcess = { Invoke-WordPostProcessItems @args }
|
||||
ProcessAllObjects = { Invoke-WordProcessAllObjects @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-WordActivate {
|
||||
# Lazy load point for the interop assembly. Activate is the first lifecycle
|
||||
# hook the engine runs for a selected provider, so this happens only when a
|
||||
# documentation run actually asks for Word output. Throwing here is
|
||||
# deliberate: the engine wraps Activate in its $recordFailure handler, so the
|
||||
# run reports "Activate failed for Word: ..." instead of failing later and
|
||||
# less clearly when Process touches an interop enum.
|
||||
if (-not (Initialize-WordInteropAssembly)) {
|
||||
throw "Word Interop assembly could not be loaded. Cannot create Word documents. Verify that Word is installed properly."
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WordPreProcessItems {
|
||||
# Validate Limit-mode bounds
|
||||
$script:limitMaxValue = 100
|
||||
$script:truncateValueLength = $script:limitMaxValue
|
||||
|
||||
if ((Get-DocumentationOutputOption word "WordDocumentationLevel" "full") -eq "limited") {
|
||||
$maxText = Get-DocumentationOutputOption word "WordDocumentationLimitMaxLength" ""
|
||||
$truncateText = Get-DocumentationOutputOption word "WordDocumentationLimitTruncateLength" ""
|
||||
|
||||
if ($maxText) {
|
||||
try { $script:limitMaxValue = [int]::Parse($maxText) }
|
||||
catch { Write-LogError "Failed to parse '$maxText' to int. Max value length will be set to 100." $_.Exception }
|
||||
}
|
||||
if ($truncateText) {
|
||||
try { $script:truncateValueLength = [int]::Parse($truncateText) }
|
||||
catch { Write-LogError "Failed to parse '$truncateText' to int. Truncate length will be set to $script:limitMaxValue." $_.Exception }
|
||||
}
|
||||
|
||||
if ($script:limitMaxValue -lt 20) {
|
||||
Write-Log "Max value length must be 20 or more. Changed to 0" 2
|
||||
$script:limitMaxValue = 0
|
||||
}
|
||||
if ($script:truncateValueLength -lt 0) {
|
||||
Write-Log "Truncate length must be 0 or more. Changed to 0" 2
|
||||
$script:truncateValueLength = 0
|
||||
}
|
||||
elseif ($script:truncateValueLength -gt $script:limitMaxValue) {
|
||||
Write-Log "Truncate length cannot be larger than Max value length. Changed to: $script:limitMaxValue" 2
|
||||
$script:truncateValueLength = $script:limitMaxValue
|
||||
}
|
||||
}
|
||||
|
||||
# Create Word COM app
|
||||
try {
|
||||
$script:wordApp = New-Object -ComObject Word.Application
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to create Word App object. Word documentation aborted..." $_.Exception
|
||||
return $false
|
||||
}
|
||||
|
||||
# Performance: suppress UI redraw and background processing while filling the document.
|
||||
# Application-level Options persist to the user profile, so we snapshot and restore them in PostProcess.
|
||||
$script:wordApp.ScreenUpdating = $false
|
||||
$script:wordApp.DisplayAlerts = 0 # wdAlertsNone
|
||||
|
||||
$script:wordOptionsBackup = $null
|
||||
try {
|
||||
$script:wordOptionsBackup = @{
|
||||
Pagination = $script:wordApp.Options.Pagination
|
||||
CheckGrammarAsYouType = $script:wordApp.Options.CheckGrammarAsYouType
|
||||
CheckSpellingAsYouType = $script:wordApp.Options.CheckSpellingAsYouType
|
||||
BackgroundSave = $script:wordApp.Options.BackgroundSave
|
||||
}
|
||||
$script:wordApp.Options.Pagination = $false
|
||||
$script:wordApp.Options.CheckGrammarAsYouType = $false
|
||||
$script:wordApp.Options.CheckSpellingAsYouType = $false
|
||||
$script:wordApp.Options.BackgroundSave = $false
|
||||
}
|
||||
catch { }
|
||||
|
||||
$template = Get-DocumentationOutputOption word "WordDocumentTemplate" ""
|
||||
if ($template) {
|
||||
try {
|
||||
$script:doc = $script:wordApp.Documents.Add($template)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to create document based on template: $template" $_.Exception
|
||||
}
|
||||
}
|
||||
else {
|
||||
$script:doc = $script:wordApp.Documents.Add()
|
||||
}
|
||||
|
||||
# Get BuiltIn properties
|
||||
$script:builtInProps = [System.Collections.Generic.List[object]]::new()
|
||||
$script:doc.BuiltInDocumentProperties | ForEach-Object {
|
||||
$name = [System.__ComObject].InvokeMember("name", [System.Reflection.BindingFlags]::GetProperty, $null, $_, $null)
|
||||
$value = $null
|
||||
try { $value = [System.__ComObject].InvokeMember("value", [System.Reflection.BindingFlags]::GetProperty, $null, $_, $null) } catch {}
|
||||
if ($name) {
|
||||
$script:builtInProps.Add([PSCustomObject]@{ Name = $name; Value = $value })
|
||||
}
|
||||
}
|
||||
|
||||
# Get Custom properties
|
||||
$script:customProps = [System.Collections.Generic.List[object]]::new()
|
||||
$script:doc.CustomDocumentProperties | ForEach-Object {
|
||||
$name = [System.__ComObject].InvokeMember("name", [System.Reflection.BindingFlags]::GetProperty, $null, $_, $null)
|
||||
$value = $null
|
||||
try { $value = [System.__ComObject].InvokeMember("value", [System.Reflection.BindingFlags]::GetProperty, $null, $_, $null) } catch {}
|
||||
if ($name) {
|
||||
$script:customProps.Add([PSCustomObject]@{ Name = $name; Value = $value })
|
||||
}
|
||||
}
|
||||
|
||||
# Style cache: O(1) lookup by NameLocal (replaces per-call linear scan in Get-DocStyle / Set-DocObjectStyle)
|
||||
$script:wordStyles = @{}
|
||||
$script:doc.Styles | ForEach-Object {
|
||||
if ($_.NameLocal -and -not $script:wordStyles.ContainsKey($_.NameLocal)) {
|
||||
$script:wordStyles[$_.NameLocal] = [PSCustomObject]@{
|
||||
Name = $_.NameLocal; Type = $_.Type; Style = $_
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Built-in style cache: same O(1) treatment for the ~376 enum names
|
||||
$script:builtinStyles = @{}
|
||||
foreach ($builtinName in [Enum]::GetNames([Microsoft.Office.Interop.Word.wdBuiltinStyle])) {
|
||||
$script:builtinStyles[$builtinName] = $true
|
||||
}
|
||||
|
||||
if (-not $template) {
|
||||
$script:doc.Application.Templates.LoadBuildingBlocks()
|
||||
$bb = $script:doc.Application.Templates | Where-Object { $_.Name -eq 'Built-In Building Blocks.dotx' }
|
||||
if ($bb) {
|
||||
$coverPageName = Get-DocumentationOutputOption word "WordCoverPage" "Ion (Dark)"
|
||||
if (-not $coverPageName) { $coverPageName = 'Ion (Dark)' }
|
||||
|
||||
try {
|
||||
$blocks = @()
|
||||
for ($i = 1; $i -le $bb.BuildingBlockEntries.Count; $i++) {
|
||||
$blocks += $bb.BuildingBlockEntries.Item($i)
|
||||
}
|
||||
$coverPages = ($blocks | Where-Object { $_.Type.Index -eq 2 } | Select-Object Name | Sort-Object -Property Name).Name
|
||||
|
||||
if (($coverPages | Measure-Object).Count -gt 0) {
|
||||
if ($coverPageName -notin $coverPages) {
|
||||
Write-Log "$coverPageName not found in available Cover Page list. Using: $($coverPages[0])"
|
||||
Write-Log "Available Cover Pages: $($coverPages -join ',')"
|
||||
$coverPageName = $coverPages[0]
|
||||
}
|
||||
else {
|
||||
Write-Log "Add Cover Page: $coverPageName"
|
||||
}
|
||||
}
|
||||
|
||||
$coverPage = $bb.BuildingBlockEntries.Item($coverPageName)
|
||||
$coverPage.Insert($script:wordApp.Selection.Range, $true) | Out-Null
|
||||
$script:wordApp.Selection.InsertNewPage()
|
||||
}
|
||||
catch { Write-LogError "Failed to create Cover Page" $_.Exception }
|
||||
|
||||
try {
|
||||
$coverPageProps = $script:doc.CustomXMLParts | Where-Object { $_.NamespaceURI -match "coverPageProps$" }
|
||||
if ($coverPageProps) {
|
||||
Write-Log "Available Cover Page properties for $($coverPageName): $((([xml]$coverPageProps.DocumentElement.XML).ChildNodes[0].ChildNodes).Name -join ',')"
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try {
|
||||
$script:doc.TablesOfContents.Add($script:wordApp.Selection.Range) | Out-Null
|
||||
$script:wordApp.Selection.InsertNewPage()
|
||||
}
|
||||
catch { Write-LogError "Failed to create Table of Contents" $_.Exception }
|
||||
}
|
||||
}
|
||||
else {
|
||||
Invoke-DocGoToEnd
|
||||
$script:wordApp.Selection.InsertNewPage()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WordPostProcessItems {
|
||||
$userName = $null
|
||||
$me = Get-CurrentUser
|
||||
if ($me) {
|
||||
if ($me.givenName -and $me.surname) {
|
||||
$userName = "$($me.givenName) $($me.surname)"
|
||||
}
|
||||
else {
|
||||
$userName = $me.displayName
|
||||
}
|
||||
}
|
||||
|
||||
$titleProp = Get-DocumentationOutputOption word "WordTitleProperty" "Intune documentation"
|
||||
$subjectProp = Get-DocumentationOutputOption word "WordSubjectProperty" "Intune documentation"
|
||||
if (-not $titleProp) { $titleProp = "Intune documentation" }
|
||||
if (-not $subjectProp) { $subjectProp = "Intune documentation" }
|
||||
|
||||
Set-WordDocBuiltInProperty "wdPropertyTitle" $titleProp
|
||||
Set-WordDocBuiltInProperty "wdPropertySubject" $subjectProp
|
||||
# Author + Company are the "who generated this" document info. Word writes them
|
||||
# as built-in file metadata (not a visible top-of-document block like the other
|
||||
# providers), but they are the same info the generic SkipDocumentInfo flag hides.
|
||||
if (-not ((Get-DocumentationOption "SkipDocumentInfo" $false) -eq $true)) {
|
||||
Set-WordDocBuiltInProperty "wdPropertyAuthor" $userName
|
||||
$orgName = Get-CurrentOrganizationName
|
||||
if ($orgName) {
|
||||
Set-WordDocBuiltInProperty "wdPropertyCompany" $orgName
|
||||
}
|
||||
}
|
||||
Set-WordDocBuiltInProperty "wdPropertyKeywords" "Intune,Endpoint Manager,MEM"
|
||||
|
||||
try {
|
||||
$controls = Get-DocumentationOutputOption word "WordContentControls" ""
|
||||
foreach ($ccObj in $controls.Split(';')) {
|
||||
$ccName, $ccVal = $ccObj.Split('=')
|
||||
Set-WordContentControlText $ccName $ccVal
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
foreach ($field in @("Fields","TablesOfContents","TablesOfFigures","TablesOfAuthorities")) {
|
||||
try { $script:doc.$field | ForEach-Object { $_.Update() | Out-Null } }
|
||||
catch { Write-LogError "Failed to update document $field" $_.Exception }
|
||||
}
|
||||
|
||||
# Restore Application Options before saving so user settings aren't permanently changed.
|
||||
try {
|
||||
if ($script:wordOptionsBackup) {
|
||||
$script:wordApp.Options.Pagination = $script:wordOptionsBackup.Pagination
|
||||
$script:wordApp.Options.CheckGrammarAsYouType = $script:wordOptionsBackup.CheckGrammarAsYouType
|
||||
$script:wordApp.Options.CheckSpellingAsYouType = $script:wordOptionsBackup.CheckSpellingAsYouType
|
||||
$script:wordApp.Options.BackgroundSave = $script:wordOptionsBackup.BackgroundSave
|
||||
}
|
||||
$script:wordApp.ScreenUpdating = $true
|
||||
$script:doc.Repaginate()
|
||||
}
|
||||
catch { }
|
||||
|
||||
$formatStr = Get-DocumentationOutputOption word "WordDocumentFormat" "wdFormatDocumentDefault"
|
||||
if ($formatStr -eq "pdf") { $formatStr = "wdFormatPDF" }
|
||||
elseif ($formatStr -eq "docx") { $formatStr = "wdFormatDocumentDefault" }
|
||||
Write-Log "Using document format: $formatStr"
|
||||
$format = $null
|
||||
try {
|
||||
$format = [Microsoft.Office.Interop.Word.WdSaveFormat]$formatStr
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Document format validation failed; defaulting to wdFormatDocumentDefault" $_.Exception
|
||||
$format = [Microsoft.Office.Interop.Word.WdSaveFormat]::wdFormatDocumentDefault
|
||||
}
|
||||
|
||||
$fileName = Get-DocumentationOutputOption word "WordDocumentName" ""
|
||||
if (-not $fileName) { $fileName = "%MyDocuments%\%Organization%-%Date%.docx" }
|
||||
$fileName = Expand-FileName $fileName
|
||||
|
||||
if ($format -eq [Microsoft.Office.Interop.Word.WdSaveFormat]::wdFormatPDF -and $fileName -notlike "*.pdf") {
|
||||
$fileName = [IO.Path]::ChangeExtension($fileName, ".pdf")
|
||||
}
|
||||
|
||||
try {
|
||||
$script:doc.SaveAs2([ref]$fileName, [ref]$format)
|
||||
Write-Log "Document $fileName saved successfully"
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to save file $fileName" $_.Exception
|
||||
}
|
||||
|
||||
try {
|
||||
$openDocSetting = Get-DocumentationOutputOption word "WordOpenDocument" "true"
|
||||
$openDoc = ($openDocSetting -ne "false") -and ($openDocSetting -ne $false)
|
||||
# Only pop Word visible in interactive UI mode; headless / silent / bulk runs
|
||||
# close it (the .docx is already saved). ($global:hideUI was a dead old-project
|
||||
# global, never assigned -> Word always opened, even during automation.)
|
||||
$hideUI = (Get-CacheObject "ShowUI") -ne $true
|
||||
if ($openDoc -and -not $hideUI) {
|
||||
$script:wordApp.Visible = $true
|
||||
$script:wordApp.WindowState = [Microsoft.Office.Interop.Word.WdWindowState]::wdWindowStateMaximize
|
||||
$script:wordApp.Activate()
|
||||
}
|
||||
else {
|
||||
$script:doc.Close([Microsoft.Office.Interop.Word.WdSaveOptions]::wdDoNotSaveChanges)
|
||||
$script:wordApp.Quit()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to close the Word application" $_.Exception
|
||||
}
|
||||
finally {
|
||||
try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($script:doc) } catch { }
|
||||
try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($script:wordApp) } catch { }
|
||||
[GC]::Collect()
|
||||
[GC]::WaitForPendingFinalizers()
|
||||
}
|
||||
}
|
||||
|
||||
function Set-WordContentControlText {
|
||||
param([string]$ControlName, $Value)
|
||||
|
||||
if (-not $ControlName) { return }
|
||||
|
||||
try {
|
||||
$ctrl = $script:doc.SelectContentControlsByTitle($ControlName)
|
||||
if ($ctrl) {
|
||||
Write-LogDebug "Update ContentControl $ControlName (Type: $($ctrl[1].Type))"
|
||||
if ($ctrl[1].Type -eq 6) {
|
||||
if ($ctrl[1].DateDisplayFormat) {
|
||||
$ctrl[1].Range.Text = (Get-Date).ToString($ctrl[1].DateDisplayFormat)
|
||||
}
|
||||
else {
|
||||
$ctrl[1].Range.Text = (Get-Date).ToShortDateString()
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (-not $Value) { return }
|
||||
$ctrl[1].Range.Text = $Value
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to set ContentControl $ControlName" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WordNewObjectGroup {
|
||||
param($groupId)
|
||||
|
||||
$header1 = Get-DocumentationOutputOption word "WordHeader1Style" "Heading 1"
|
||||
if (-not $header1) { $header1 = "Heading 1" }
|
||||
Add-DocText (Get-DocObjectTypeString $groupId) $header1
|
||||
}
|
||||
|
||||
function Invoke-WordNewObjectType {
|
||||
param($objectTypeName)
|
||||
|
||||
$script:objectHeaderLevel = 2
|
||||
Add-DocText $objectTypeName (Get-ObjectLevelHeader)
|
||||
$script:objectHeaderLevel = 3
|
||||
}
|
||||
|
||||
function Get-ObjectLevelHeader {
|
||||
if ($script:objectHeaderLevel -eq 3) {
|
||||
$h3 = Get-DocumentationOutputOption word "WordHeader3Style" ""
|
||||
if ($h3) { return $h3 }
|
||||
}
|
||||
$h2 = Get-DocumentationOutputOption word "WordHeader2Style" "Heading 2"
|
||||
if (-not $h2) { $h2 = "Heading 2" }
|
||||
return $h2
|
||||
}
|
||||
|
||||
function Invoke-WordProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
# A documented object may ask to be titled by something other than its display
|
||||
# name (see Get-DocumentationDisplayName). Headings and captions follow it; the
|
||||
# file name below deliberately does not.
|
||||
$objName = Get-DocumentationDisplayName $PolicyObject $documentedObj
|
||||
$script:docDisplayName = $objName
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
Add-DocText $objName (Get-ObjectLevelHeader)
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
|
||||
$propMode = Get-DocumentationOutputOption word "WordExportProperties" "simple"
|
||||
$customProps = Get-DocumentationOutputOption word "WordCustomDisplayProperties" ""
|
||||
$docLevel = Get-DocumentationOutputOption word "WordDocumentationLevel" "full"
|
||||
$addCategories = (Get-DocumentationOutputOption word "WordAddCategories" $true) -eq $true
|
||||
$addSubCats = (Get-DocumentationOutputOption word "WordAddSubCategories" $true) -eq $true
|
||||
$attachJson = (Get-DocumentationOutputOption word "WordAttachJsonFile" $false) -eq $true
|
||||
|
||||
try {
|
||||
foreach ($tableType in @("BasicInfo","FilteredSettings")) {
|
||||
if ($tableType -eq "BasicInfo") {
|
||||
$properties = @("Name","Value")
|
||||
}
|
||||
elseif ($propMode -eq 'extended' -and $documentedObj.DisplayProperties) {
|
||||
$properties = @("Name","Value","Description")
|
||||
}
|
||||
elseif ($propMode -eq 'custom' -and $customProps) {
|
||||
$properties = @()
|
||||
foreach ($prop in $customProps.Split(",")) {
|
||||
$propInfo = $prop.Split('=')
|
||||
if (($propInfo | Measure-Object).Count -gt 1) {
|
||||
$properties += $propInfo[0]
|
||||
Set-DocColumnHeaderLanguageId $propInfo[0] $propInfo[1]
|
||||
}
|
||||
else {
|
||||
$properties += $prop
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($documentedObj.DefaultDocumentationProperties) {
|
||||
$properties = $documentedObj.DefaultDocumentationProperties
|
||||
}
|
||||
else {
|
||||
$properties = @("Name","Value")
|
||||
}
|
||||
}
|
||||
|
||||
if ($docLevel -eq "basic" -and $tableType -ne "BasicInfo") { continue }
|
||||
|
||||
# Custom tables with a negative Order belong ABOVE the settings
|
||||
# table: the portal shows a MAM app config's "Settings catalog"
|
||||
# blade above its "Settings" blade.
|
||||
if ($tableType -eq "FilteredSettings") {
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -lt 0 } | Sort-Object -Property Order)) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns -LngId $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.$tableType | Measure-Object).Count -gt 0) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $documentedObj.$tableType $properties -AddCategories:$addCategories -AddSubcategories:$addSubCats -ForceFullValue:($tableType -eq "BasicInfo")
|
||||
}
|
||||
}
|
||||
|
||||
if ($docLevel -ne "basic") {
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $documentedObj.ComplianceActions @("Action","Schedule","MessageTemplate","EmailCC") -LngId "Category.complianceActionsLabel"
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $documentedObj.ApplicabilityRules @("Rule","Property","Value") -LngId "SettingDetails.applicabilityRules"
|
||||
}
|
||||
|
||||
Add-DocObjectScripts $documentedObj
|
||||
|
||||
# Negative Order already rendered above the settings table.
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -ge 0 } | Sort-Object -Property Order)) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns -LngId $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
$settingProps = $null
|
||||
if ($documentedObj.Assignments[0].RawIntent) {
|
||||
$properties = @("GroupMode","Group","Filter","FilterMode")
|
||||
$settingProps = @("Filter","FilterMode")
|
||||
$settingsObj = $documentedObj.Assignments | Where-Object { $_.Settings -ne $null } | Select-Object -First 1
|
||||
if ($settingsObj) {
|
||||
foreach ($objProp in $settingsObj.Settings.Keys) {
|
||||
if ($objProp -in $properties) { continue }
|
||||
if ($objProp -in @("Category","RawIntent")) { continue }
|
||||
$settingProps += "Settings.$objProp"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$hasFilter = $false
|
||||
foreach ($a in $documentedObj.Assignments) {
|
||||
if ($a.PSObject.Properties.Name -contains "FilterMode") { $hasFilter = $true; break }
|
||||
}
|
||||
$properties = @("Group")
|
||||
if ($hasFilter) { $properties += @("Filter","FilterMode") }
|
||||
}
|
||||
|
||||
Add-DocTableItems $PolicyObject $typeTitle $documentedObj.Assignments $properties -LngId "TableHeaders.assignments" -AddCategories
|
||||
|
||||
if ($null -ne $settingProps) {
|
||||
# Adds additional values to the assignments table for Apps assignments
|
||||
Set-DocTableSettingsItems $documentedObj.Assignments $settingProps 3
|
||||
}
|
||||
}
|
||||
|
||||
if ($attachJson) {
|
||||
# Embed the full raw object JSON as an OLE object (old feature gated on
|
||||
# chkWordAttachJsonFile). The full object is already in hand, so write it
|
||||
# to a temp file directly rather than depending on an external exporter.
|
||||
try {
|
||||
# The policy's real name, not the heading: the heading may be a
|
||||
# DocumentName override, and this is the attached object's label.
|
||||
$safeName = ([string]$PolicyObject.Name)
|
||||
foreach ($ch in [IO.Path]::GetInvalidFileNameChars()) { $safeName = $safeName.Replace($ch, '_') }
|
||||
if ([string]::IsNullOrEmpty($safeName)) { $safeName = 'object' }
|
||||
$fi = [IO.FileInfo](Join-Path ([IO.Path]::GetTempPath()) "$safeName.json")
|
||||
($PolicyObject.JsonObject | ConvertTo-Json -Depth 50) | Out-File -LiteralPath $fi.FullName -Encoding UTF8
|
||||
$fi.Refresh()
|
||||
if ($fi.Exists) {
|
||||
$script:doc.Application.Selection.InlineShapes.AddOLEObject("", $fi.FullName, $false, $true, "$($env:WinDir)\System32\Notepad.exe", 0, $fi.Name)
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
try { $fi.Delete() } catch { }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to attach JSON for $objName" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
function Set-DocTableSettingsItems {
|
||||
param($items, $properties, [int]$firstColumn)
|
||||
|
||||
$secondColumn = $firstColumn + 1
|
||||
|
||||
$script:docTable.Cell(1, $firstColumn).Range.Text = (Invoke-DocTranslateColumnHeader "Settings")
|
||||
$script:docTable.Cell(1, $secondColumn).Range.Text = ""
|
||||
|
||||
$row = 2
|
||||
foreach ($itemObj in $items) {
|
||||
while ($script:docTable.Cell($row, 1).Next.RowIndex -gt $row) {
|
||||
# Category / Sub-category row — skip
|
||||
$row++
|
||||
}
|
||||
$script:docTable.Cell($row, $firstColumn).Range.Text = ""
|
||||
$script:docTable.Cell($row, $secondColumn).Range.Text = ""
|
||||
$script:docTable.Cell($row, $firstColumn).Split($properties.Count, 1)
|
||||
$script:docTable.Cell($row, $secondColumn).Split($properties.Count, 1)
|
||||
|
||||
$cellRow = $row
|
||||
foreach ($settingProp in $properties) {
|
||||
if ([string]::IsNullOrEmpty($settingProp)) { continue }
|
||||
|
||||
$script:docTable.Cell($cellRow, $firstColumn).Range.Text = (Invoke-DocTranslateColumnHeader ($settingProp.Split('.')[-1]))
|
||||
|
||||
$propArr = $settingProp.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
|
||||
$script:docTable.Cell($cellRow, $secondColumn).Range.Text = "$($tmpObj.$propName)"
|
||||
$cellRow++
|
||||
}
|
||||
$row = $row + $properties.Count
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WordProcessAllObjects {
|
||||
param($allObjectTypeObjects)
|
||||
# ScopeTags consolidated table is deferred — depends on Get-TableObjects-style
|
||||
# cross-object accumulation that hasn't been ported yet. Phase 2 re-enables.
|
||||
}
|
||||
|
||||
function Add-DocTableItems {
|
||||
param(
|
||||
$PolicyObject,
|
||||
[string]$TypeTitle,
|
||||
$Items,
|
||||
[string[]]$Properties,
|
||||
[string]$LngId,
|
||||
[switch]$AddCategories,
|
||||
[switch]$AddSubcategories,
|
||||
$CaptionOverride,
|
||||
[switch]$ForceFullValue
|
||||
)
|
||||
|
||||
if (($Items | Measure-Object).Count -eq 0) { return }
|
||||
|
||||
$tblHeaderStyle = Get-DocumentationOutputOption word "WordTableHeaderStyle" ""
|
||||
$tblCategoryStyle = Get-DocumentationOutputOption word "WordCategoryHeaderStyle" ""
|
||||
$tblSubCategoryStyle = Get-DocumentationOutputOption word "WordSubCategoryHeaderStyle" ""
|
||||
$tblTextStyle = Get-DocumentationOutputOption word "WordTableTextStyle" ""
|
||||
$tblStyle = Get-DocumentationOutputOption word "WordTableStyle" "Grid table 4 - Accent 3"
|
||||
$captionPos = Get-DocumentationOutputOption word "WordTableCaptionPosition" "below"
|
||||
$docLevel = Get-DocumentationOutputOption word "WordDocumentationLevel" "full"
|
||||
$limitAttach = (Get-DocumentationOutputOption word "WordDocumentationLimitAttach" $false) -eq $true
|
||||
|
||||
$range = $script:doc.Application.Selection.Range
|
||||
|
||||
# Pre-pass: count category / sub-category rows so the table can be allocated at its final size.
|
||||
# Boundary logic MUST stay in sync with the main fill loop below.
|
||||
$extraRows = 0
|
||||
$preCat = ""
|
||||
$preSubCat = ""
|
||||
foreach ($itemObj in $Items) {
|
||||
if ($itemObj.Category -and $preCat -ne $itemObj.Category -and $AddCategories) {
|
||||
$extraRows++
|
||||
$preCat = $itemObj.Category
|
||||
$preSubCat = ""
|
||||
}
|
||||
if ($itemObj.SubCategory -and $preSubCat -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
$extraRows++
|
||||
$preSubCat = $itemObj.SubCategory
|
||||
}
|
||||
}
|
||||
|
||||
$totalRows = @($Items).Count + 1 + $extraRows
|
||||
|
||||
# Create with wdAutoFitFixed during fill — wdAutoFitWindow recalculates column widths after every cell write.
|
||||
# We re-enable wdAutoFitWindow once after the rows are populated.
|
||||
$script:docTable = $script:doc.Tables.Add($range, $totalRows, $Properties.Count, [Microsoft.Office.Interop.Word.WdDefaultTableBehavior]::wdWord9TableBehavior, [Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitFixed)
|
||||
$script:docTable.ApplyStyleHeadingRows = $true
|
||||
Set-DocObjectStyle $script:docTable $tblStyle | Out-Null
|
||||
|
||||
if ($CaptionOverride) {
|
||||
$caption = $CaptionOverride
|
||||
}
|
||||
elseif ($LngId -and $PolicyObject) {
|
||||
$caption = "$((Get-LanguageString $LngId)) - $(Get-DocCaptionName $PolicyObject)"
|
||||
}
|
||||
elseif ($PolicyObject) {
|
||||
$caption = "$(Get-DocCaptionName $PolicyObject) ($TypeTitle)"
|
||||
}
|
||||
else {
|
||||
$caption = $TypeTitle
|
||||
}
|
||||
|
||||
$i = 1
|
||||
foreach ($prop in $Properties) {
|
||||
if ([string]::IsNullOrEmpty($prop)) { continue }
|
||||
$script:docTable.Cell(1, $i).Range.Text = (Invoke-DocTranslateColumnHeader ($prop.Split('.')[-1]))
|
||||
$i++
|
||||
}
|
||||
|
||||
if (-not (Set-DocObjectStyle $script:docTable.Rows(1).Range $tblHeaderStyle)) {
|
||||
$script:docTable.Rows(1).Range.Font.Size += 2
|
||||
$script:docTable.Rows(1).Range.Font.Bold = $true
|
||||
}
|
||||
|
||||
$curCategory = ""
|
||||
$curSubCategory = ""
|
||||
|
||||
$row = 2
|
||||
foreach ($itemObj in $Items) {
|
||||
try {
|
||||
if ($itemObj.Category -and $curCategory -ne $itemObj.Category -and $AddCategories) {
|
||||
try { $script:docTable.Rows.Item($row).Cells.Merge() } catch { }
|
||||
$script:docTable.Cell($row, 1).Range.Text = $itemObj.Category
|
||||
|
||||
if (-not (Set-DocObjectStyle $script:docTable.Rows($row).Range $tblCategoryStyle)) {
|
||||
$script:docTable.Rows($row).Range.Font.Size += 2
|
||||
$script:docTable.Rows($row).Range.Font.Italic = $true
|
||||
}
|
||||
|
||||
$row++
|
||||
$curCategory = $itemObj.Category
|
||||
$curSubCategory = ""
|
||||
}
|
||||
|
||||
if ($itemObj.SubCategory -and $curSubCategory -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
try { $script:docTable.Rows.Item($row).Cells.Merge() } catch { }
|
||||
$script:docTable.Cell($row, 1).Range.Text = $itemObj.SubCategory
|
||||
|
||||
if (-not (Set-DocObjectStyle $script:docTable.Rows($row).Range $tblSubCategoryStyle)) {
|
||||
$script:docTable.Rows($row).Range.Font.Italic = $true
|
||||
}
|
||||
|
||||
$row++
|
||||
$curSubCategory = $itemObj.SubCategory
|
||||
}
|
||||
|
||||
$i = 1
|
||||
foreach ($prop in $Properties) {
|
||||
try {
|
||||
$propArr = $prop.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
$propValue = "$($tmpObj.$propName)"
|
||||
$propValueFull = $null
|
||||
|
||||
if (-not $ForceFullValue -and $docLevel -eq "limited" -and $propValue.Length -gt $script:limitMaxValue) {
|
||||
$propValueFull = $propValue
|
||||
if ($script:truncateValueLength -gt 0) {
|
||||
$propValue = $propValue.Substring(0, $script:truncateValueLength) + "..."
|
||||
if ($limitAttach) { $propValue = "`r`n" + $propValue }
|
||||
}
|
||||
else {
|
||||
$propValue = $null
|
||||
}
|
||||
}
|
||||
|
||||
$levelExtra = ""
|
||||
if ($i -eq 1 -and $itemObj.Level) {
|
||||
try {
|
||||
$level = [int]$itemObj.Level
|
||||
if ($level -lt 0) { $level = 0 }
|
||||
if ($level -gt 0) {
|
||||
$levelExtra = [string]::new(" ", ($level * 2))
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if ($null -ne $propValue) {
|
||||
$script:docTable.Cell($row, $i).Range.Text = "$levelExtra$propValue"
|
||||
}
|
||||
|
||||
if ($propValueFull -and $limitAttach) {
|
||||
$tmpName = "$($PolicyObject.Name)-$propName"
|
||||
$tmpFile = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "$tmpName.txt")
|
||||
$tmpFile = Remove-InvalidFileNameChars $tmpFile
|
||||
$propValueFull | Out-File -LiteralPath $tmpFile -Force
|
||||
$fi = [IO.FileInfo]$tmpFile
|
||||
[void]$script:docTable.Cell($row, $i).Range.InlineShapes.AddOLEObject("", $fi.FullName, $false, $true, "$($env:WinDir)\System32\Notepad.exe", 0, "Full value")
|
||||
try { $fi.Delete() } catch { }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add property value for $prop" $_.Exception
|
||||
}
|
||||
$i++
|
||||
}
|
||||
|
||||
Set-DocObjectStyle $script:docTable.Rows($row).Range $tblTextStyle | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to process property" 2
|
||||
}
|
||||
|
||||
$row++
|
||||
}
|
||||
|
||||
try { $script:docTable.AutoFitBehavior([Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitWindow) } catch { }
|
||||
|
||||
# -2 = Table caption, 1 = Below / 0 = Above
|
||||
$capPos = if ($captionPos -eq "above") { 0 } else { 1 }
|
||||
$script:docTable.Application.Selection.InsertCaption(-2, ". $caption", $null, $capPos)
|
||||
|
||||
Invoke-DocGoToEnd
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
}
|
||||
|
||||
function Add-DocTableScript {
|
||||
param([string]$Caption, [string]$Header, [string]$ScriptText)
|
||||
|
||||
if (-not $ScriptText) { return }
|
||||
|
||||
$primary = Get-DocumentationOutputOption word "WordScriptTableStyle" ""
|
||||
if (-not $primary) {
|
||||
$primary = Get-DocumentationOutputOption word "WordTableStyle" "Grid table 4 - Accent 3"
|
||||
}
|
||||
$scriptStyle = Get-DocumentationOutputOption word "WordScriptStyle" ""
|
||||
|
||||
$range = $script:doc.Application.Selection.Range
|
||||
$scriptTable = $script:doc.Tables.Add($range, 2, 1, [Microsoft.Office.Interop.Word.WdDefaultTableBehavior]::wdWord9TableBehavior, [Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitFixed)
|
||||
$scriptTable.ApplyStyleHeadingRows = $true
|
||||
Set-DocObjectStyle $scriptTable $primary | Out-Null
|
||||
|
||||
if ($Header) {
|
||||
$scriptTable.Cell(1, 1).Range.Text = $Header
|
||||
}
|
||||
|
||||
$scriptTable.Cell(2, 1).Range.Font.Bold = $false
|
||||
$scriptTable.Cell(2, 1).Range.Text = $ScriptText
|
||||
if ($scriptStyle) {
|
||||
Set-DocObjectStyle $scriptTable.Rows(2).Range $scriptStyle | Out-Null
|
||||
}
|
||||
else {
|
||||
$tmp = $script:wordStyles["HTML Code"]
|
||||
if ($tmp) {
|
||||
$scriptTable.Cell(2, 1).Range.Font = $tmp.Style.Font
|
||||
}
|
||||
$scriptTable.Cell(2, 1).Range.Font.Bold = $false
|
||||
}
|
||||
$scriptTable.Cell(2, 1).Range.NoProofing = $true
|
||||
|
||||
try { $scriptTable.AutoFitBehavior([Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitWindow) } catch { }
|
||||
$scriptTable.Application.Selection.InsertCaption(-2, ". $Caption", $null, 1)
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
}
|
||||
|
||||
function Get-DocStyle {
|
||||
param([string]$StyleName)
|
||||
|
||||
$tmpStyle = $null
|
||||
if ($StyleName -and $script:wordStyles.ContainsKey($StyleName)) {
|
||||
$tmpStyle = $script:wordStyles[$StyleName].Style
|
||||
}
|
||||
if (-not $tmpStyle) { Write-Log "Style $StyleName not found" }
|
||||
$tmpStyle
|
||||
}
|
||||
|
||||
function Add-DocText {
|
||||
param([string]$Text, [string]$Style, [switch]$SkipAddParagraph)
|
||||
|
||||
Set-DocObjectStyle $script:doc.Application.Selection $Style | Out-Null
|
||||
$script:doc.Application.Selection.TypeText($Text)
|
||||
if (-not $SkipAddParagraph) {
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-DocGoToEnd {
|
||||
$script:doc.Application.Selection.GoTo([Microsoft.Office.Interop.Word.WdGoToItem]::wdGoToBookmark, $null, $null, '\EndOfDoc') | Out-Null
|
||||
}
|
||||
|
||||
function Set-WordDocBuiltInProperty {
|
||||
param([string]$PropertyName, $Value)
|
||||
|
||||
try {
|
||||
$script:doc.BuiltInDocumentProperties([Microsoft.Office.Interop.Word.WdBuiltInProperty]$PropertyName) = $Value
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to set built in property $PropertyName to $Value" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
function Set-DocObjectStyle {
|
||||
param($DocObj, [string]$ObjStyle)
|
||||
|
||||
$styleSet = $false
|
||||
if ($DocObj -and $ObjStyle) {
|
||||
try {
|
||||
if ($script:builtinStyles.ContainsKey($ObjStyle)) {
|
||||
$DocObj.style = [Microsoft.Office.Interop.Word.wdBuiltinStyle]$ObjStyle
|
||||
}
|
||||
else {
|
||||
$DocObj.style = $ObjStyle
|
||||
}
|
||||
$styleSet = $true
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to set style: $ObjStyle" 3
|
||||
}
|
||||
}
|
||||
$styleSet
|
||||
}
|
||||
|
||||
function Add-DocObjectScripts {
|
||||
param($documentedObj)
|
||||
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent -or -not $scriptItem.Caption) { continue }
|
||||
Add-DocTableScript $scriptItem.Caption $scriptItem.Header $scriptItem.ScriptContent
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeWordOutput
|
||||
@@ -0,0 +1,294 @@
|
||||
# Android Managed Store App Configuration documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:1928. Claims
|
||||
# @odata.type='#microsoft.graph.androidManagedStoreAppConfiguration' and
|
||||
# (per the plan's batching) also the legacy androidForWorkMobileAppConfig
|
||||
# variant since both have the same shape.
|
||||
#
|
||||
# Profile applicability translates to one of three workProfile/deviceOwner
|
||||
# variants which becomes the "Profile type" basic-info value.
|
||||
# Outlook ObjectInfo translation deferred until the walker is ported.
|
||||
|
||||
class AppConfigAndroidStoreDocHandler : DocumentationHandlerBase {
|
||||
AppConfigAndroidStoreDocHandler() {
|
||||
$this.ODataTypes = @(
|
||||
'#microsoft.graph.androidManagedStoreAppConfiguration',
|
||||
'#microsoft.graph.androidForWorkMobileAppConfiguration'
|
||||
)
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# ProfileType maps to a localized "App configuration" suffix
|
||||
$profileString = switch ($obj.profileApplicability) {
|
||||
'default' { Get-LanguageString 'ProfileType.workProfileAndDeviceOwner' }
|
||||
'androidWorkProfile' { Get-LanguageString 'ProfileType.workProfileOnly' }
|
||||
'androidDeviceOwner' { Get-LanguageString 'ProfileType.deviceOwnerOnly' }
|
||||
default { $null }
|
||||
}
|
||||
# Pass profileString as the Profile-type override so Add-BasicDefaultValues
|
||||
# emits one (and only one) Profile type row matching old engine's pattern
|
||||
# at DocumentationCustom.psm1:1955.
|
||||
Add-BasicDefaultValues $PolicyObject $profileString
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
# Targeted apps — resolved to displayNames when catalog available
|
||||
$allApps = Get-CDAllTenantApps
|
||||
$appsList = @()
|
||||
foreach ($id in $obj.targetedMobileApps) {
|
||||
$app = $allApps | Where-Object Id -EQ $id | Select-Object -First 1
|
||||
$appsList += if ($app -and $app.displayName) { $app.displayName } else { $id }
|
||||
}
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.targetedAppLabel') ($appsList -join $Context.ObjectSeparator) 'targetedMobileApps'
|
||||
|
||||
if ($obj.packageId) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.packageId') $obj.packageId 'packageId'
|
||||
}
|
||||
|
||||
# appSupportsOemConfig is the discriminator the portal uses to split OEMConfig
|
||||
# policies into their own blade - surface it so an OEMConfig policy isn't
|
||||
# documented as an ordinary app configuration. NB: TableHeaders.configurationType
|
||||
# renders as "Profile type" and would collide with the row above.
|
||||
if ($obj.appSupportsOemConfig -eq $true) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.policyType') (Get-LanguageString 'ConfigurationTypes.androidForWorkOemConfig') 'appSupportsOemConfig'
|
||||
}
|
||||
|
||||
# connectedAppsEnabled - "Connected apps" toggle. The portal offers
|
||||
# Enabled / Not configured (not Enabled/Disabled).
|
||||
$connKey = if ($obj.connectedAppsEnabled -eq $true) { 'Inputs.enabled' } else { 'Inputs.notConfigured' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.connectedApps') (Get-LanguageString $connKey) 'connectedAppsEnabled'
|
||||
|
||||
# credentialProviderRoleState - androidAppCredentialProviderRoleState enum
|
||||
# (notConfigured / allowed only). The portal renders the same two options as
|
||||
# the connected-apps toggle: Enabled / Not configured.
|
||||
$credKeys = @{
|
||||
'notConfigured' = 'Inputs.notConfigured'
|
||||
'allowed' = 'Inputs.enabled'
|
||||
}
|
||||
$credRaw = "$($obj.credentialProviderRoleState)"
|
||||
if ($credRaw) {
|
||||
$credKey = $credKeys[$credRaw]
|
||||
$credValue = if ($credKey) { Get-LanguageString $credKey -IgnoreMissing } else { $null }
|
||||
if ([string]::IsNullOrEmpty($credValue)) { $credValue = $credRaw }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.credentialProvider') $credValue 'credentialProviderRoleState'
|
||||
}
|
||||
|
||||
if (-not $obj.payloadJson) { return }
|
||||
|
||||
$payloadData = $null
|
||||
try {
|
||||
$payloadData = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($obj.payloadJson)) | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
Write-LogError 'Failed to parse Android managed-store payloadJson' $_.Exception
|
||||
return
|
||||
}
|
||||
|
||||
# Outlook gets schema-driven translation (port of old DocumentationCustom.psm1:1987-2005).
|
||||
if ($obj.packageId -eq 'com.microsoft.office.outlook') {
|
||||
$hasAccountType = @($payloadData.managedProperty | Where-Object { $_.key -eq 'com.microsoft.outlook.EmailProfile.AccountType' })
|
||||
$outlookSettings = [PSCustomObject]@{ configureEmail = [bool]$hasAccountType }
|
||||
foreach ($mp in @($payloadData.managedProperty)) {
|
||||
$valueProp = $mp.PSObject.Properties | Where-Object Name -Like 'value*' | Select-Object -First 1
|
||||
$outlookSettings | Add-Member -MemberType NoteProperty -Name $mp.key -Value $valueProp.Value -Force
|
||||
}
|
||||
Invoke-DocAppConfigManifest $outlookSettings (Join-Path (Join-Path $script:AppRootFolder 'Config\ObjectInfo') '#AppConfigOutlookDevice.json') $Context
|
||||
}
|
||||
|
||||
# Outlook translation applied above; remaining managedProperty entries
|
||||
# fall through to the additional-settings table.
|
||||
$addedSettings = Get-DocumentedSettings
|
||||
|
||||
# Friendly names / descriptions / enum labels come from the app's own
|
||||
# managed-configuration schema when a tenant is reachable.
|
||||
$schema = Get-CDAndroidAppConfigSchema $obj.packageId
|
||||
|
||||
$additionalSettings = @()
|
||||
$hasDescription = $false
|
||||
foreach ($row in (Expand-AndroidManagedProperties $payloadData.managedProperty '' 0 $schema)) {
|
||||
if ($addedSettings | Where-Object EntityKey -EQ $row.Key) { continue }
|
||||
if ($row.Description) { $hasDescription = $true }
|
||||
|
||||
$additionalSettings += [PSCustomObject]@{
|
||||
Name = $row.Name
|
||||
Key = $row.Key
|
||||
ValueType = $row.ValueType
|
||||
Value = $row.Value
|
||||
Description = $row.Description
|
||||
EntityKey = $row.Key
|
||||
Category = Get-LanguageString 'TACSettings.generalSettings'
|
||||
SubCategory = Get-LanguageString 'SettingDetails.additionalConfiguration'
|
||||
}
|
||||
}
|
||||
if ($additionalSettings.Count -gt 0) {
|
||||
# Keep the raw key visible next to the friendly name, and only add the
|
||||
# description column when the schema actually supplied any.
|
||||
$columns = if ($hasDescription) { @('Name','Key','ValueType','Value','Description') } else { @('Name','Key','ValueType','Value') }
|
||||
Add-CustomTable 'AdditionalSettings' $columns $additionalSettings -Order 110
|
||||
}
|
||||
|
||||
# Permissions table. Portal grid is 4 columns: friendly name, permission
|
||||
# state, raw permission name (prefix stripped) and permission group.
|
||||
$permissions = @()
|
||||
foreach ($p in $obj.permissionActions) {
|
||||
$tail = $p.permission.Split('.')[-1]
|
||||
$permissionStr = if ($tail) {
|
||||
# Language ids drop the underscores (READ_CALENDAR -> readCalendar);
|
||||
# PowerShell member lookup is case-insensitive so the raw upper-case
|
||||
# form resolves too.
|
||||
$lngId = $tail -replace '_',''
|
||||
$resolved = Get-LanguageString "AndroidForWorkAppPermissions.Permissions.$lngId" -IgnoreMissing
|
||||
if ($resolved) { $resolved } else { $tail }
|
||||
} else { $p.permission }
|
||||
|
||||
$actionStr = $p.action
|
||||
$resolvedAction = Get-LanguageString "AndroidForWorkAppPermissions.Action.$($p.action)" -IgnoreMissing
|
||||
if ($resolvedAction) { $actionStr = $resolvedAction }
|
||||
|
||||
$permissions += [PSCustomObject]@{
|
||||
Permission = $permissionStr
|
||||
PermissionState = $actionStr
|
||||
PermissionName = $tail
|
||||
PermissionGroup = Get-AndroidPermissionGroup $tail
|
||||
EntityKey = $p.permission
|
||||
}
|
||||
}
|
||||
if ($permissions.Count -gt 0) {
|
||||
Add-CustomTable 'Permissions' @('Permission','PermissionState','PermissionName','PermissionGroup') $permissions -Order 115 -LanguageId 'AndroidForWorkAppPermissions.permissionsTitle'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Managed-configuration schema for one Managed Google Play app. The portal uses
|
||||
# this to show friendly names instead of raw keys, a description column, the real
|
||||
# data type (choice/multiselect/bundle...) and enum labels via `selections`.
|
||||
#
|
||||
# GET /deviceManagement/androidManagedStoreAppConfigurationSchemas('app:<packageId>')
|
||||
#
|
||||
# Returns a hashtable keyed by schemaItemKey. `nestedSchemaItems` carries the
|
||||
# members of bundles/bundle arrays (linked to their parent by index/parentIndex),
|
||||
# so nested leaves get friendly names too. Cached per run and per package; offline
|
||||
# / source-unavailable returns an empty map and every caller degrades to raw keys.
|
||||
function Get-CDAndroidAppConfigSchema {
|
||||
param([string]$PackageId)
|
||||
|
||||
if (-not $PackageId) { return @{} }
|
||||
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
if (-not $ctx.PSObject.Properties['_AndroidAppConfigSchemas']) {
|
||||
$ctx | Add-Member -MemberType NoteProperty -Name '_AndroidAppConfigSchemas' -Value (@{}) -Force
|
||||
}
|
||||
if ($ctx._AndroidAppConfigSchemas.ContainsKey($PackageId)) { return $ctx._AndroidAppConfigSchemas[$PackageId] }
|
||||
|
||||
$map = @{}
|
||||
# The schema is generic app metadata (same on every tenant), so this is gated on
|
||||
# connectivity only - not on SourceTenantUnavailable.
|
||||
if (Test-DocumentationGraphAvailable) {
|
||||
try {
|
||||
$url = "/deviceManagement/androidManagedStoreAppConfigurationSchemas('app:$PackageId')"
|
||||
$resp = Invoke-MSGraphAPI -Url $url -ODataMetadata 'minimal' -NoError
|
||||
foreach ($item in @($resp.schemaItems) + @($resp.nestedSchemaItems)) {
|
||||
if ($item.schemaItemKey -and -not $map.ContainsKey($item.schemaItemKey)) {
|
||||
$map[$item.schemaItemKey] = $item
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to load Android app configuration schema for $PackageId" $_.Exception
|
||||
}
|
||||
}
|
||||
$ctx._AndroidAppConfigSchemas[$PackageId] = $map
|
||||
return $map
|
||||
}
|
||||
|
||||
# Android permission -> permission group. Groups are Android platform constants
|
||||
# (not localized - the portal renders them verbatim in its 4th grid column).
|
||||
$script:_androidPermissionGroups = @{
|
||||
'READ_CALENDAR' = 'CALENDAR'; 'WRITE_CALENDAR' = 'CALENDAR'
|
||||
'CAMERA' = 'CAMERA'
|
||||
'READ_CONTACTS' = 'CONTACTS'; 'WRITE_CONTACTS' = 'CONTACTS'; 'GET_ACCOUNTS' = 'CONTACTS'
|
||||
'ACCESS_FINE_LOCATION' = 'LOCATION'; 'ACCESS_COARSE_LOCATION' = 'LOCATION'; 'ACCESS_BACKGROUND_LOCATION' = 'LOCATION'
|
||||
'RECORD_AUDIO' = 'MICROPHONE'
|
||||
'READ_PHONE_STATE' = 'PHONE'; 'CALL_PHONE' = 'PHONE'; 'READ_CALL_LOG' = 'PHONE'; 'WRITE_CALL_LOG' = 'PHONE'
|
||||
'ADD_VOICEMAIL' = 'PHONE'; 'USE_SIP' = 'PHONE'; 'PROCESS_OUTGOING_CALLS' = 'PHONE'
|
||||
'BODY_SENSORS' = 'SENSORS'; 'BODY_SENSORS_BACKGROUND' = 'SENSORS'
|
||||
'SEND_SMS' = 'SMS'; 'RECEIVE_SMS' = 'SMS'; 'READ_SMS' = 'SMS'; 'RECEIVE_WAP_PUSH' = 'SMS'; 'RECEIVE_MMS' = 'SMS'
|
||||
'READ_EXTERNAL_STORAGE' = 'STORAGE'; 'WRITE_EXTERNAL_STORAGE' = 'STORAGE'
|
||||
'POST_NOTIFICATIONS' = 'NOTIFICATIONS'
|
||||
'READ_MEDIA_VIDEO' = 'MEDIA'; 'READ_MEDIA_IMAGES' = 'MEDIA'; 'READ_MEDIA_AUDIO' = 'MEDIA'
|
||||
'BLUETOOTH_CONNECT' = 'DEVICES'; 'NEARBY_WIFI_DEVICES' = 'DEVICES'; 'NEARBY_DEVICES' = 'DEVICES'
|
||||
}
|
||||
|
||||
function Get-AndroidPermissionGroup {
|
||||
param([string]$PermissionName)
|
||||
if (-not $PermissionName) { return $null }
|
||||
$script:_androidPermissionGroups[$PermissionName]
|
||||
}
|
||||
|
||||
# Flatten a Google managed-configuration `managedProperty` array into one row per
|
||||
# LEAF value. The payload supports six value shapes, two of which nest without
|
||||
# bound (portal JSON-editor schema: valueBool / valueInteger / valueString /
|
||||
# valueStringArray / valueBundle / valueBundleArray):
|
||||
#
|
||||
# valueBundle -> { managedProperty: [ ... ] } rendered as "parent.child"
|
||||
# valueBundleArray -> [ { managedProperty: [...] }, ... ] rendered as "parent[0].child"
|
||||
#
|
||||
# Without this, a bundle rendered as the PowerShell object stringification and a
|
||||
# bundle array rendered as a bare "," (the -join of an array of objects).
|
||||
function Expand-AndroidManagedProperties {
|
||||
param($ManagedProperties, [string]$Prefix = '', [int]$Depth = 0, $Schema = @{})
|
||||
|
||||
if ($Depth -gt 10) {
|
||||
Write-Log 'Android app config: managedProperty nesting deeper than 10 levels; remaining levels not documented' 2
|
||||
return
|
||||
}
|
||||
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
|
||||
foreach ($mp in @($ManagedProperties)) {
|
||||
if (-not $mp) { continue }
|
||||
$key = if ($Prefix) { "$Prefix$($mp.key)" } else { [string]$mp.key }
|
||||
|
||||
$valueProp = $mp.PSObject.Properties | Where-Object Name -Like 'value*' | Select-Object -First 1
|
||||
if (-not $valueProp) { continue }
|
||||
|
||||
# Schema is keyed by the app's own schemaItemKey, not by our dotted path
|
||||
$schemaItem = $Schema[[string]$mp.key]
|
||||
|
||||
switch ($valueProp.Name) {
|
||||
'valueBundle' {
|
||||
Expand-AndroidManagedProperties $valueProp.Value.managedProperty "$key." ($Depth + 1) $Schema
|
||||
}
|
||||
'valueBundleArray' {
|
||||
$idx = 0
|
||||
foreach ($bundle in @($valueProp.Value)) {
|
||||
Expand-AndroidManagedProperties $bundle.managedProperty "$key[$idx]." ($Depth + 1) $Schema
|
||||
$idx++
|
||||
}
|
||||
}
|
||||
default {
|
||||
$val = $valueProp.Value
|
||||
# choice / multiselect store the selection VALUE; the schema carries
|
||||
# the friendly name for each in `selections`
|
||||
if ($schemaItem.selections) {
|
||||
$val = @($val | ForEach-Object {
|
||||
$raw = $_
|
||||
$sel = $schemaItem.selections | Where-Object { "$($_.value)" -eq "$raw" } | Select-Object -First 1
|
||||
if ($sel.name) { $sel.name } else { $raw }
|
||||
})
|
||||
}
|
||||
if ($val -is [array]) { $val = $val -join $ctx.ObjectSeparator }
|
||||
|
||||
[PSCustomObject]@{
|
||||
Key = $key
|
||||
Name = ?? $schemaItem.displayName $key
|
||||
ValueType = if ($schemaItem.dataType) { $schemaItem.dataType } else { $valueProp.Name.Substring(5) }
|
||||
Value = $val
|
||||
Description = $schemaItem.description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([AppConfigAndroidStoreDocHandler]::new())
|
||||
@@ -0,0 +1,192 @@
|
||||
# iOS Mobile App Configuration documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:2065. Claims
|
||||
# @odata.type='#microsoft.graph.iosMobileAppConfiguration'.
|
||||
#
|
||||
# Two main paths:
|
||||
# 1. iOS plist (base64'd encodedSettingXml) — parsed offline, key/value/type
|
||||
# rows emitted directly
|
||||
# 2. settings collection (Outlook-specific or generic appConfig key/value)
|
||||
# The Outlook ObjectInfo translation needs the ObjectInfo JSON walker
|
||||
# (deferred); offline we fall through to raw key=value rows under the
|
||||
# generic "Additional configuration" subcategory.
|
||||
|
||||
class AppConfigMobileAppDocHandler : DocumentationHandlerBase {
|
||||
AppConfigMobileAppDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.iosMobileAppConfiguration')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'SettingDetails.appConfiguration') '@odata.type'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.enrollmentTypeLabel') (Get-LanguageString 'EnrollmentType.devicesWithEnrollment') 'enrollmentType'
|
||||
|
||||
$platformId = Get-ObjectPlatformFromType $obj
|
||||
if ($platformId) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.platformLabel') (Get-LanguageString "Platform.$platformId") 'platform'
|
||||
}
|
||||
|
||||
# Targeted apps — resolve IDs to displayNames when the tenant catalog is
|
||||
# available, otherwise emit raw IDs.
|
||||
$allApps = Get-CDAllTenantApps
|
||||
$appsList = @()
|
||||
foreach ($id in $obj.targetedMobileApps) {
|
||||
$app = $allApps | Where-Object Id -EQ $id | Select-Object -First 1
|
||||
$appsList += if ($app -and $app.displayName) { $app.displayName } else { $id }
|
||||
}
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.targetedAppLabel') ($appsList -join $Context.ObjectSeparator) 'targetedMobileApps'
|
||||
|
||||
$category = Get-LanguageString 'TableHeaders.settings'
|
||||
|
||||
if ($obj.encodedSettingXml) {
|
||||
# iOS plist. The portal emits a bare <dict> root but Graph also accepts a
|
||||
# <plist> wrapper, and the portal's validator explicitly allows nested
|
||||
# <dict>/<array> values - so the walk has to recurse.
|
||||
$xml = $null
|
||||
try {
|
||||
$xml = [xml]([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($obj.encodedSettingXml)))
|
||||
}
|
||||
catch {
|
||||
Write-LogError 'Failed to convert iOS encodedSettingXml to XML' $_.Exception
|
||||
return
|
||||
}
|
||||
|
||||
$rootDict = if ($xml.dict) { $xml.dict } elseif ($xml.plist.dict) { $xml.plist.dict } else { $null }
|
||||
if (-not $rootDict) {
|
||||
Write-Log 'iOS app config: encodedSettingXml has no <dict> root; no settings documented' 2
|
||||
return
|
||||
}
|
||||
|
||||
$plistRows = @(Expand-IosPlistDictionary $rootDict)
|
||||
|
||||
# ValueType is not part of the default documentation properties, so also
|
||||
# emit the portal's 3-column grid (key / value type / value).
|
||||
if ($plistRows.Count -gt 0) {
|
||||
$typeRows = foreach ($row in $plistRows) {
|
||||
[PSCustomObject]@{
|
||||
ConfigurationKey = $row.Key
|
||||
ValueType = Get-AppConfigValueTypeName $row.ValueType
|
||||
ConfigurationValue = $row.Value
|
||||
EntityKey = $row.Key
|
||||
}
|
||||
}
|
||||
Add-CustomTable 'AppConfigSettings' @('ConfigurationKey','ValueType','ConfigurationValue') $typeRows -Order 110 -LanguageId 'TableHeaders.settings'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
# Outlook gets schema-driven translation (port of old DocumentationCustom.psm1:2141-2176).
|
||||
$isOutlook = $false
|
||||
foreach ($id in $obj.targetedMobileApps) {
|
||||
$app = $allApps | Where-Object Id -EQ $id | Select-Object -First 1
|
||||
if ($app.displayName -eq 'Microsoft Outlook') { $isOutlook = $true; break }
|
||||
}
|
||||
if (-not $isOutlook -and @($obj.settings | Where-Object { $_.appConfigKey -like 'com.microsoft.outlook*' })) { $isOutlook = $true }
|
||||
if ($isOutlook) {
|
||||
$hasAccountType = @($obj.settings | Where-Object { $_.appConfigKey -eq 'com.microsoft.outlook.EmailProfile.AccountType' })
|
||||
$outlookSettings = [PSCustomObject]@{ configureEmail = [bool]$hasAccountType }
|
||||
foreach ($setting in @($obj.settings)) {
|
||||
$val = if ($setting.appConfigKeyType -eq 'booleanType') { $setting.appConfigKeyValue -eq 'true' } else { $setting.appConfigKeyValue }
|
||||
$outlookSettings | Add-Member -MemberType NoteProperty -Name $setting.appConfigKey -Value $val -Force
|
||||
}
|
||||
Invoke-DocAppConfigManifest $outlookSettings (Join-Path (Join-Path $script:AppRootFolder 'Config\ObjectInfo') '#AppConfigOutlookDevice.json') $Context
|
||||
}
|
||||
|
||||
# Remaining settings fall through to raw key=value rows under the
|
||||
# "Additional configuration" subcategory.
|
||||
$addedSettings = Get-DocumentedSettings
|
||||
$languageTitleId = 'TableHeaders.settings'
|
||||
|
||||
$typeRows = @()
|
||||
foreach ($setting in $obj.settings) {
|
||||
if ($addedSettings | Where-Object EntityKey -EQ $setting.appConfigKey) {
|
||||
$languageTitleId = 'SettingDetails.additionalConfiguration'
|
||||
continue
|
||||
}
|
||||
|
||||
# The portal grid shows the value TYPE as its own column; keep that
|
||||
# (a tokenType value like {{userprincipalname}} is not a literal string).
|
||||
$typeRows += [PSCustomObject]@{
|
||||
ConfigurationKey = $setting.appConfigKey
|
||||
ValueType = Get-AppConfigValueTypeName $setting.appConfigKeyType
|
||||
ConfigurationValue = $setting.appConfigKeyValue
|
||||
EntityKey = $setting.appConfigKey
|
||||
}
|
||||
}
|
||||
if ($typeRows.Count -gt 0) {
|
||||
Add-CustomTable 'AppConfigSettings' @('ConfigurationKey','ValueType','ConfigurationValue') $typeRows -Order 110 -LanguageId $languageTitleId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Localized name for an app-config value type. Accepts both the Graph
|
||||
# mdmAppConfigKeyType members (stringType/integerType/realType/booleanType/
|
||||
# tokenType) and the bare plist element names (string/integer/real/boolean/...).
|
||||
# tokenType has no language string - the portal shows it blank, so the raw value
|
||||
# is a strict improvement.
|
||||
function Get-AppConfigValueTypeName {
|
||||
param([string]$ValueType)
|
||||
|
||||
if (-not $ValueType) { return $null }
|
||||
$key = switch -Regex ($ValueType) {
|
||||
'^string' { 'SettingDetails.string' }
|
||||
'^integer' { 'SettingDetails.integer' }
|
||||
'^real' { 'SettingDetails.real' }
|
||||
'^boolean' { 'SettingDetails.boolean' }
|
||||
default { $null }
|
||||
}
|
||||
if (-not $key) { return $ValueType }
|
||||
$value = Get-LanguageString $key -IgnoreMissing
|
||||
if ([string]::IsNullOrEmpty($value)) { $ValueType } else { $value }
|
||||
}
|
||||
|
||||
# Flatten an iOS plist <dict> into one row per LEAF value. Nested containers are
|
||||
# addressed the way the plist itself addresses them:
|
||||
# <dict> -> "parent.child"
|
||||
# <array> -> "parent[0]"
|
||||
# Without this, a nested container produced an EMPTY value (.'#text' on an element
|
||||
# with element children returns nothing) and the payload was silently lost.
|
||||
function Expand-IosPlistDictionary {
|
||||
param($DictNode, [string]$Prefix = '', [int]$Depth = 0)
|
||||
|
||||
if ($Depth -gt 10) {
|
||||
Write-Log 'iOS app config: plist nesting deeper than 10 levels; remaining levels not documented' 2
|
||||
return
|
||||
}
|
||||
|
||||
$children = @($DictNode.ChildNodes)
|
||||
for ($i = 0; $i -lt $children.Count; $i++) {
|
||||
if ($children[$i].Name -ne 'key') { continue }
|
||||
$name = $children[$i].'#text'
|
||||
$i++
|
||||
if ($i -ge $children.Count) { break }
|
||||
$valueNode = $children[$i]
|
||||
$key = if ($Prefix) { "$Prefix$name" } else { [string]$name }
|
||||
|
||||
switch ($valueNode.Name) {
|
||||
'true' { [PSCustomObject]@{ Key = $key; ValueType = 'boolean'; Value = 'true' } }
|
||||
'false' { [PSCustomObject]@{ Key = $key; ValueType = 'boolean'; Value = 'false' } }
|
||||
'dict' { Expand-IosPlistDictionary $valueNode "$key." ($Depth + 1) }
|
||||
'array' {
|
||||
$idx = 0
|
||||
foreach ($item in @($valueNode.ChildNodes)) {
|
||||
$itemKey = "$key[$idx]"
|
||||
if ($item.Name -eq 'dict') { Expand-IosPlistDictionary $item "$itemKey." ($Depth + 1) }
|
||||
elseif ($item.Name -eq 'true' -or $item.Name -eq 'false') {
|
||||
[PSCustomObject]@{ Key = $itemKey; ValueType = 'boolean'; Value = $item.Name }
|
||||
}
|
||||
else {
|
||||
[PSCustomObject]@{ Key = $itemKey; ValueType = $item.Name; Value = $item.'#text' }
|
||||
}
|
||||
$idx++
|
||||
}
|
||||
}
|
||||
default { [PSCustomObject]@{ Key = $key; ValueType = $valueNode.Name; Value = $valueNode.'#text' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([AppConfigMobileAppDocHandler]::new())
|
||||
@@ -0,0 +1,48 @@
|
||||
# Assignment Filter documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:3823 (Invoke-CDDocument-
|
||||
# AssignmentFilter, ~35 LOC). Claims @odata.type='#microsoft.graph.deviceAnd
|
||||
# AppManagementAssignmentFilter' and produces a 7-row BasicInfo + 1-row
|
||||
# Settings table (the rule syntax).
|
||||
#
|
||||
# Platform value: app-management platforms (androidMobileApplicationManagement
|
||||
# etc.) resolve to empty strings in Strings-en.json which Get-LanguageString
|
||||
# returns as $null — so BasicInfo emits the row with Value=null, matching the
|
||||
# golden's `"Platform": null` for app filters.
|
||||
|
||||
class AssignmentFilterDocHandler : DocumentationHandlerBase {
|
||||
AssignmentFilterDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.deviceAndAppManagementAssignmentFilter')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# BasicInfo order: Name, Description, Created, Last modified, Profile type, Platform
|
||||
# (Scope tags appended automatically by the engine's post-step.)
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'Filters.filters') '@odata.type'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.platformLabel') (Get-LanguageString "Platform.$($obj.platform)") 'platform'
|
||||
|
||||
# Filter scope: devices vs apps. Disambiguates app-management filters, whose
|
||||
# platform row resolves to null (see header note).
|
||||
$mgmtType = switch ($obj.assignmentFilterManagementType) {
|
||||
'devices' { Get-LanguageString 'Titles.devices' }
|
||||
'apps' { Get-LanguageString 'Titles.apps' }
|
||||
default { $obj.assignmentFilterManagementType }
|
||||
}
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.managementType') $mgmtType 'assignmentFilterManagementType'
|
||||
|
||||
# Settings: a single Rule syntax row
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'Filters.ruleSyntax'
|
||||
Value = $obj.rule
|
||||
EntityKey = 'rule'
|
||||
Category = Get-LanguageString 'SettingDetails.rules'
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([AssignmentFilterDocHandler]::new())
|
||||
@@ -0,0 +1,44 @@
|
||||
# Authentication Context documentation handler.
|
||||
#
|
||||
# Claims @odata.type='#microsoft.graph.authenticationContextClassReference'. Auth
|
||||
# contexts (c1..c99) were previously only referenced by Conditional Access policies
|
||||
# (ID -> displayName, see ConditionalAccessDocHandler); this handler documents the
|
||||
# standalone object: display name, description and whether it is published to apps
|
||||
# (isAvailable).
|
||||
#
|
||||
# Note: AuthenticationContextType strips @odata.type on export (_PropertiesToRemove),
|
||||
# so this handler matches live documentation runs. File-based runs of an exported
|
||||
# auth context lose the discriminator and fall through to NoProvider - a pre-existing
|
||||
# export-cleanup limitation, not addressed here.
|
||||
|
||||
class AuthenticationContextDocHandler : DocumentationHandlerBase {
|
||||
AuthenticationContextDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.authenticationContextClassReference')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# No plain-noun language string exists for the auth-context type, so use the
|
||||
# PolicyType title for the Profile type row.
|
||||
$nameValue = if ($obj.displayName) { $obj.displayName } else { $PolicyObject.Name }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $nameValue 'displayName'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') $PolicyObject.PolicyType.Title '@odata.type'
|
||||
if ($obj.description) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.descriptionName') $obj.description 'description'
|
||||
}
|
||||
|
||||
if ($null -ne $obj.isAvailable) {
|
||||
$availKey = if ($obj.isAvailable -eq $true) { 'Inputs.enabled' } else { 'Inputs.disabled' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.AuthContext.InfoBlade.publishLabel')
|
||||
Value = (Get-LanguageString $availKey)
|
||||
Category = $null
|
||||
SubCategory = $null
|
||||
EntityKey = 'isAvailable'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([AuthenticationContextDocHandler]::new())
|
||||
@@ -0,0 +1,76 @@
|
||||
# Authentication Strength documentation handler.
|
||||
#
|
||||
# Claims @odata.type='#microsoft.graph.authenticationStrengthPolicy'. Authentication
|
||||
# strengths were previously only referenced as a Conditional Access grant control
|
||||
# (ID -> displayName, see ConditionalAccessDocHandler); this handler documents the
|
||||
# standalone policy object.
|
||||
#
|
||||
# The policy's substance is allowedCombinations: an OR-list of method combinations,
|
||||
# where each combination is an AND-set of authentication methods (comma-joined in
|
||||
# the raw value, e.g. "password,microsoftAuthenticatorPush"). Each method maps to
|
||||
# AzureCA.AuthenticationStrength.Mode.<method>. A few methods (federatedMultiFactor,
|
||||
# federatedSingleFactor) have no Mode string yet, so fall back to a humanised token
|
||||
# rather than emit a raw enum value.
|
||||
|
||||
class AuthenticationStrengthDocHandler : DocumentationHandlerBase {
|
||||
AuthenticationStrengthDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.authenticationStrengthPolicy')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# BasicInfo: Name + Profile type + Description
|
||||
$nameValue = if ($obj.displayName) { $obj.displayName } else { $PolicyObject.Name }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $nameValue 'displayName'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'AzureCA.WhatIfBlade.authenticationStrength') '@odata.type'
|
||||
if ($obj.description) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.descriptionName') $obj.description 'description'
|
||||
}
|
||||
|
||||
# allowedCombinations: OR-list of AND-combinations. Render each combination
|
||||
# as "Method A + Method B" on its own line.
|
||||
$comboLines = @()
|
||||
foreach ($combo in @($obj.allowedCombinations)) {
|
||||
if ([string]::IsNullOrWhiteSpace($combo)) { continue }
|
||||
$methodNames = @()
|
||||
foreach ($method in ($combo -split ',')) {
|
||||
$m = $method.Trim()
|
||||
if (-not $m) { continue }
|
||||
$methodNames += (Get-AuthenticationMethodLabel $m)
|
||||
}
|
||||
if ($methodNames.Count -gt 0) {
|
||||
$comboLines += ($methodNames -join ' + ')
|
||||
}
|
||||
}
|
||||
|
||||
if ($comboLines.Count -gt 0) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.policyControlAuthenticationStrengthDisplayedName')
|
||||
Value = ($comboLines -join $Context.ObjectSeparator)
|
||||
Category = $null
|
||||
SubCategory = $null
|
||||
EntityKey = 'allowedCombinations'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Map an authentication-method enum token to its localised label, falling back to
|
||||
# a humanised form (federatedMultiFactor -> "Federated Multi Factor") for tokens
|
||||
# that have no Mode string. -IgnoreMissing keeps the log clean for known gaps.
|
||||
function Get-AuthenticationMethodLabel {
|
||||
param([string]$Method)
|
||||
|
||||
if ([string]::IsNullOrEmpty($Method)) { return $Method }
|
||||
|
||||
$label = Get-LanguageString "AzureCA.AuthenticationStrength.Mode.$Method" -IgnoreMissing
|
||||
if (-not [string]::IsNullOrEmpty($label)) { return $label }
|
||||
|
||||
# No Mode string: split camelCase into Title-cased words.
|
||||
$spaced = [regex]::Replace($Method, '(?<=[a-z0-9])(?=[A-Z])', ' ')
|
||||
$ci = [System.Globalization.CultureInfo]::InvariantCulture
|
||||
return (($spaced -split ' ' | Where-Object { $_ } | ForEach-Object { $ci.TextInfo.ToTitleCase($_.ToLower()) }) -join ' ')
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([AuthenticationStrengthDocHandler]::new())
|
||||
@@ -0,0 +1,53 @@
|
||||
# Co-Management Settings documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:3862. Hardcoded
|
||||
# Platform = Windows 10 (Co-Management is Windows-only).
|
||||
|
||||
class CoManagementDocHandler : DocumentationHandlerBase {
|
||||
CoManagementDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.deviceComanagementAuthorityConfiguration')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') ((Get-LanguageString 'WindowsEnrollment.coManagementAuthorityTitle').Trim()) '@odata.type'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.platformLabel') (Get-LanguageString 'Platform.Windows10') 'platform'
|
||||
|
||||
$category = Get-LanguageString 'TableHeaders.settings'
|
||||
$yes = Get-LanguageString 'BooleanActions.yes'
|
||||
$no = Get-LanguageString 'SettingDetails.no'
|
||||
|
||||
$installValue = if ($obj.installConfigurationManagerAgent -eq $true) { $yes } else { $no }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'CoManagementAuthority.installAgent')
|
||||
Value = $installValue
|
||||
EntityKey = 'installConfigurationManagerAgent'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
if ($obj.installConfigurationManagerAgent -eq $true) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'CoManagementAuthority.commandLineArgs')
|
||||
Value = $obj.configurationManagerAgentCommandLineArgument
|
||||
EntityKey = 'configurationManagerAgentCommandLineArgument'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
$ownershipValue = if ($obj.managedDeviceAuthority -eq 1) { $yes } else { $no }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'CoManagementAuthority.managedDeviceOwnership')
|
||||
Value = $ownershipValue
|
||||
EntityKey = 'managedDeviceAuthority'
|
||||
Category = $category
|
||||
SubCategory = (Get-LanguageString 'CoManagementAuthority.advancedProperty')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([CoManagementDocHandler]::new())
|
||||
@@ -0,0 +1,73 @@
|
||||
# Custom compliance script documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:4788
|
||||
# (Invoke-CDDocumentDeviceComplianceScript). Claims
|
||||
# @odata.type='#microsoft.graph.deviceComplianceScript'.
|
||||
#
|
||||
# deviceComplianceScript has no ObjectCategories entry, so - like the Scope
|
||||
# Tag handler - basic info rows are emitted manually instead of via
|
||||
# Add-BasicDefaultValues (which would add blank Platform/Profile rows).
|
||||
|
||||
class ComplianceScriptDocHandler : DocumentationHandlerBase {
|
||||
ComplianceScriptDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.deviceComplianceScript')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
if ($PolicyObject.Name) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $PolicyObject.Name 'displayName'
|
||||
}
|
||||
$descValue = if ($obj.description) { $obj.description } else { '' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.descriptionName') $descValue 'description'
|
||||
if ($obj.publisher) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.publisher') $obj.publisher 'publisher'
|
||||
}
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'Titles.complianceScriptManagementPreview') 'configurationType'
|
||||
|
||||
$category = Get-LanguageString 'TableHeaders.settings'
|
||||
$valueYes = Get-LanguageString 'BooleanActions.yes'
|
||||
$valueNo = Get-LanguageString 'SettingDetails.no'
|
||||
|
||||
if ($obj.detectionScriptContent -and -not ($Context.Options -and $Context.Options['IncludeScripts'] -eq $false)) {
|
||||
$scriptBody = ''
|
||||
try { $scriptBody = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($obj.detectionScriptContent)) } catch { }
|
||||
if ($scriptBody) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'ProactiveRemediations.Create.Settings.DetectionScriptMultiLineTextBox.label'
|
||||
Value = $scriptBody
|
||||
EntityKey = 'detectionScriptContent'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'WindowsManagement.scriptContextLabel'
|
||||
Value = $(if ($obj.runAsAccount -eq 'system') { $valueNo } else { $valueYes })
|
||||
EntityKey = 'runAsAccount'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'WindowsManagement.enforceSignatureCheckLabel'
|
||||
Value = $(if ($obj.enforceSignatureCheck -eq $false) { $valueNo } else { $valueYes })
|
||||
EntityKey = 'enforceSignatureCheck'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'WindowsManagement.runAs64BitLabel'
|
||||
Value = $(if ($obj.runAs32Bit -eq $true) { $valueNo } else { $valueYes })
|
||||
EntityKey = 'runAs32Bit'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([ComplianceScriptDocHandler]::new())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
# Custom OMA-URI documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:3519. Emits Name +
|
||||
# Platform basic info, then 4-5 rows per OMA-URI setting (Name, Description,
|
||||
# OMA-URI path, Data type, Value). Encrypted values are skipped offline;
|
||||
# live runs fetch via /deviceConfigurations/.../getOmaSettingPlainTextValue.
|
||||
#
|
||||
# Claims all 4 CustomConfiguration variants in one handler.
|
||||
|
||||
class CustomOMAUriDocHandler : DocumentationHandlerBase {
|
||||
CustomOMAUriDocHandler() {
|
||||
$this.ODataTypes = @(
|
||||
'#microsoft.graph.windows10CustomConfiguration',
|
||||
'#microsoft.graph.androidForWorkCustomConfiguration',
|
||||
'#microsoft.graph.androidWorkProfileCustomConfiguration',
|
||||
'#microsoft.graph.androidCustomConfiguration'
|
||||
)
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
# Note: old code at L3534 has the configurationType BasicPropertyValue
|
||||
# commented out. Faithful port — skipping.
|
||||
|
||||
$platformId = Get-ObjectPlatformFromType $obj
|
||||
if ($platformId) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.platformLabel') (Get-LanguageString "Platform.$platformId") 'platform'
|
||||
}
|
||||
|
||||
$category = Get-LanguageString 'SettingDetails.customPolicyOMAURISettingsName'
|
||||
|
||||
$typeLabelMap = @{
|
||||
'#microsoft.graph.omaSettingString' = 'SettingDetails.stringName'
|
||||
'#microsoft.graph.omaSettingBase64' = 'SettingDetails.base64Name'
|
||||
'#microsoft.graph.omaSettingBoolean' = 'SettingDetails.booleanName'
|
||||
'#microsoft.graph.omaSettingDateTime' = 'SettingDetails.dateTimeName'
|
||||
'#microsoft.graph.omaSettingFloatingPoint' = 'SettingDetails.floatingPointName'
|
||||
'#microsoft.graph.omaSettingInteger' = 'SettingDetails.integerName'
|
||||
'#microsoft.graph.omaSettingStringXml' = 'SettingDetails.stringXMLName'
|
||||
}
|
||||
|
||||
foreach ($setting in $obj.omaSettings) {
|
||||
$sub = $setting.displayName
|
||||
$oma = $setting.omaUri
|
||||
$type = if ($setting.PSObject.Properties['@OData.Type']) { $setting.'@OData.Type' } else { $setting.'@odata.type' }
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.nameName')
|
||||
Value = $setting.displayName
|
||||
EntityKey = "displayName_$oma"
|
||||
Category = $category; SubCategory = $sub
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TableHeaders.description')
|
||||
Value = $setting.description
|
||||
EntityKey = "description_$oma"
|
||||
Category = $category; SubCategory = $sub
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.oMAURIName')
|
||||
Value = $oma
|
||||
EntityKey = "omaUri_$oma"
|
||||
Category = $category; SubCategory = $sub
|
||||
})
|
||||
|
||||
$typeKey = $typeLabelMap[$type]
|
||||
if ($typeKey) {
|
||||
$typeValue = Get-LanguageString $typeKey
|
||||
if ($typeValue) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.dataTypeName')
|
||||
Value = $typeValue
|
||||
EntityKey = "type_$oma"
|
||||
Category = $category; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
# Value row — skip when encrypted unless we can resolve via Graph
|
||||
if ($setting.isEncrypted -ne $true) {
|
||||
$value = $setting.value
|
||||
if ($type -eq '#microsoft.graph.omaSettingStringXml' -and $value) {
|
||||
try { $value = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($value)) } catch { }
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.valueName')
|
||||
Value = $value
|
||||
EntityKey = "value_$oma"
|
||||
Category = $category; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
elseif (-not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable) -and $setting.secretReferenceValueId) {
|
||||
try {
|
||||
$url = "/deviceManagement/deviceConfigurations/$($obj.id)/getOmaSettingPlainTextValue(secretReferenceValueId='$($setting.secretReferenceValueId)')"
|
||||
$resp = Invoke-MSGraphAPI -Url $url
|
||||
if ($resp.Value) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.valueName')
|
||||
Value = $resp.Value
|
||||
EntityKey = "value_$oma"
|
||||
Category = $category; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
}
|
||||
catch { Write-LogError "Failed to resolve encrypted OMA-URI value for $oma" $_.Exception }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([CustomOMAUriDocHandler]::new())
|
||||
@@ -0,0 +1,24 @@
|
||||
# Device Category documentation handler.
|
||||
#
|
||||
# Claims @odata.type='#microsoft.graph.deviceCategory'. Device categories are
|
||||
# name + description only; there is no ObjectCategories entry (so no
|
||||
# Add-BasicDefaultValues - it would emit blank Platform/Profile rows) and no
|
||||
# old-project documenter to port.
|
||||
|
||||
class DeviceCategoryDocHandler : DocumentationHandlerBase {
|
||||
DeviceCategoryDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.deviceCategory')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
if ($PolicyObject.Name) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $PolicyObject.Name 'displayName'
|
||||
}
|
||||
$descValue = if ($obj.description) { $obj.description } else { '' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.descriptionName') $descValue 'description'
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([DeviceCategoryDocHandler]::new())
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
# Device Enrollment Platform Restriction documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:4345. Claims the two
|
||||
# platform-restriction @odata.types:
|
||||
# ...deviceEnrollmentPlatformRestrictionConfiguration (single platform)
|
||||
# ...deviceEnrollmentPlatformRestrictionsConfiguration (all platforms — the
|
||||
# aggregate that emits
|
||||
# one row block per
|
||||
# platform sub-restriction)
|
||||
#
|
||||
# Doesn't handle deviceEnrollmentLimitConfiguration — that has a different
|
||||
# shape (single "Device limit" setting) and lives behind the generic Profile
|
||||
# input provider in the old engine.
|
||||
|
||||
class EnrollmentPlatformRestrictionDocHandler : DocumentationHandlerBase {
|
||||
EnrollmentPlatformRestrictionDocHandler() {
|
||||
$this.ODataTypes = @(
|
||||
'#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
|
||||
'#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration'
|
||||
)
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'Titles.deviceTypeEnrollmentRestrictions') '@odata.type'
|
||||
|
||||
# platformType (single variant) -> Platform.* language id
|
||||
$singlePlatformLngId = switch ($obj.platformType) {
|
||||
'androidForWork' { 'androidWorkProfile' }
|
||||
'mac' { 'macOS' }
|
||||
'ios' { 'iOS' }
|
||||
'android' { 'android' }
|
||||
'windows' { 'windows' }
|
||||
'tvos' { 'tvOS' }
|
||||
'visionOS' { 'visionOS' }
|
||||
default { $obj.platformType }
|
||||
}
|
||||
|
||||
$isAggregate = $obj.'@odata.type' -eq '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration'
|
||||
if ($isAggregate) {
|
||||
# The default "All users and all devices" config carries one sub-restriction
|
||||
# per platform. Graph exposes 10, including the legacy macRestriction (a dupe
|
||||
# of the version-capable macOSRestriction) and the deprecated
|
||||
# windowsMobileRestriction. Render the current platforms; prefer
|
||||
# macOSRestriction over macRestriction. $platformMap: property -> name key.
|
||||
$platform = Get-LanguageString 'AzureCA.classicPolicyAllPlatforms'
|
||||
$platformMap = [ordered]@{
|
||||
'androidForWorkRestriction' = 'Platform.androidWorkProfile'
|
||||
'androidRestriction' = 'Platform.android'
|
||||
'iosRestriction' = 'Platform.iOS'
|
||||
'macOSRestriction' = 'Platform.macOS'
|
||||
'tvosRestriction' = 'Platform.tvOS'
|
||||
'visionOSRestriction' = 'Platform.visionOS'
|
||||
'windowsRestriction' = 'Platform.windows'
|
||||
'windowsHomeSkuRestriction' = 'Devices.windowsHomeSku'
|
||||
}
|
||||
}
|
||||
else {
|
||||
$platform = Get-LanguageString "Platform.$singlePlatformLngId"
|
||||
$platformMap = [ordered]@{ 'platformRestriction' = "Platform.$singlePlatformLngId" }
|
||||
}
|
||||
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.platformLabel') $platform 'platformType'
|
||||
|
||||
$allowStr = Get-LanguageString 'BooleanActions.allow'
|
||||
$blockStr = Get-LanguageString 'BooleanActions.block'
|
||||
$category = Get-LanguageString 'EnrollmentRestrictions.DeviceType.platformSettings'
|
||||
$cantRestrictStr = Get-LanguageString 'EnrollmentRestrictions.DeviceType.cannotRestrict'
|
||||
|
||||
foreach ($prop in $platformMap.Keys) {
|
||||
$restrict = $obj.$prop
|
||||
if (-not $restrict) { continue }
|
||||
|
||||
$nameKey = $platformMap[$prop]
|
||||
$typeStr = Get-LanguageString $nameKey
|
||||
|
||||
# OS version range, blank when unset. macOSRestriction is version-capable,
|
||||
# so (unlike the legacy macRestriction the old handler forced to "cannot
|
||||
# restrict") every platform now reports its actual osMin/osMax range.
|
||||
$version = if ($restrict.osMinimumVersion -or $restrict.osMaximumVersion) {
|
||||
"$($restrict.osMinimumVersion)-$($restrict.osMaximumVersion)"
|
||||
} else { '' }
|
||||
|
||||
# Manufacturer blocking: old code has a typo (`'andriod'` instead of
|
||||
# `'android'`) which means only 'androidWorkProfile' actually emits
|
||||
# the blockedManufacturers list. Everything else — including the
|
||||
# correctly-spelled 'android' (device administrator) — falls
|
||||
# through to "Restriction not supported". Preserving the behavior
|
||||
# because golden fixtures match it; the typo is a known wart in
|
||||
# old code that fixing would silently change output.
|
||||
$blockedManufacturers = if ($nameKey -eq 'Platform.androidWorkProfile') {
|
||||
@($restrict.blockedManufacturers) -join $Context.PropertySeparator
|
||||
} else {
|
||||
$cantRestrictStr
|
||||
}
|
||||
|
||||
# Aggregate variant uses platform name as SubCategory; single variant uses $null
|
||||
$subCategory = if ($isAggregate) { $typeStr } else { $null }
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'EnrollmentRestrictions.DeviceType.type')
|
||||
Value = $typeStr
|
||||
EntityKey = 'platformType'
|
||||
Category = $category; SubCategory = $subCategory
|
||||
})
|
||||
|
||||
$platformAccess = if ($restrict.platformBlocked) { $blockStr } else { $allowStr }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'EnrollmentRestrictions.DeviceType.platform')
|
||||
Value = $platformAccess
|
||||
EntityKey = 'platformBlocked'
|
||||
Category = $category; SubCategory = $subCategory
|
||||
})
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'EnrollmentRestrictions.DeviceType.versions')
|
||||
Value = $version
|
||||
EntityKey = 'versions'
|
||||
Category = $category; SubCategory = $subCategory
|
||||
})
|
||||
|
||||
$personalAccess = if ($restrict.personalDeviceEnrollmentBlocked) { $blockStr } else { $allowStr }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'EnrollmentRestrictions.DeviceType.personal')
|
||||
Value = $personalAccess
|
||||
EntityKey = 'personalDeviceEnrollmentBlocked'
|
||||
Category = $category; SubCategory = $subCategory
|
||||
})
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'EnrollmentRestrictions.DeviceType.deviceManufacturer')
|
||||
Value = $blockedManufacturers
|
||||
EntityKey = 'blockedManufacturers'
|
||||
Category = $category; SubCategory = $subCategory
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([EnrollmentPlatformRestrictionDocHandler]::new())
|
||||
@@ -0,0 +1,206 @@
|
||||
# Managed App Configuration documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:2196. Claims
|
||||
# @odata.type='#microsoft.graph.targetedManagedAppConfiguration'.
|
||||
#
|
||||
# Outlook + Edge ObjectInfo translations (and the Edge bookmark/AllowList/
|
||||
# BlockList delimiter rewrites) deferred until the walker is ported. Offline
|
||||
# falls through to raw customSettings under TACSettings.generalSettings.
|
||||
|
||||
class ManagedAppConfigDocHandler : DocumentationHandlerBase {
|
||||
ManagedAppConfigDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.targetedManagedAppConfiguration')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'SettingDetails.appConfiguration') '@odata.type'
|
||||
|
||||
$customApps, $publishedApps = Get-CDMobileApps $obj.Apps
|
||||
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.enrollmentTypeLabel') (Get-LanguageString 'EnrollmentType.devicesWithoutEnrollment') 'enrollmentType'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.publicApps') ($publishedApps -join $Context.ObjectSeparator) 'publishedApps'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.customApps') ($customApps -join $Context.ObjectSeparator) 'customApps'
|
||||
|
||||
# appGroupType - "Target policy to". The all* variants don't list individual
|
||||
# apps, so surfacing the mode is what tells the reader the scope. Graph's
|
||||
# enum member is allCoreMicrosoftApps; the portal's string is coreMicrosoftApps.
|
||||
$appGroupTypeKeys = @{
|
||||
'selectedPublicApps' = 'AppGroupType.selectedPublicApps'
|
||||
'allApps' = 'AppGroupType.allApps'
|
||||
'allMicrosoftApps' = 'AppGroupType.allMicrosoftApps'
|
||||
'allCoreMicrosoftApps' = 'AppGroupType.coreMicrosoftApps'
|
||||
}
|
||||
$agtRaw = "$($obj.appGroupType)"
|
||||
if ($agtRaw) {
|
||||
$agtKey = $appGroupTypeKeys[$agtRaw]
|
||||
$agtValue = if ($agtKey) { Get-LanguageString $agtKey -IgnoreMissing } else { $null }
|
||||
if ([string]::IsNullOrEmpty($agtValue)) { $agtValue = $agtRaw }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.targetPolicyTo') $agtValue 'appGroupType'
|
||||
}
|
||||
|
||||
# targetedAppManagementLevels - flags enum, returned as a comma-separated
|
||||
# string (e.g. "mdm, androidEnterprise"). Map each flag to its label,
|
||||
# falling back to the raw flag value when no string exists.
|
||||
$mgmtLevelKeys = @{
|
||||
'unspecified' = 'AppProtection.allAppTypes'
|
||||
'unmanaged' = 'AppProtection.appsOnUnmanagedDevices'
|
||||
'mdm' = 'AppProtection.appsOnIntuneManagedDevices'
|
||||
'androidEnterprise' = 'AppProtection.appsInAndroidWorkProfile'
|
||||
'androidEnterpriseDedicatedDevicesWithAzureAdSharedMode' = 'AppProtection.appsOnAndroidEnterpriseDedicatedDevicesWithAzureAdSharedMode'
|
||||
'androidOpenSourceProjectUserAssociated' = 'AppProtection.appsOnAndroidOpenSourceProjectUserAssociated'
|
||||
'androidOpenSourceProjectUserless' = 'AppProtection.appsOnAndroidOpenSourceProjectUserless'
|
||||
}
|
||||
$mgmtRaw = "$($obj.targetedAppManagementLevels)"
|
||||
if ($mgmtRaw) {
|
||||
$mgmtParts = @()
|
||||
foreach ($lvl in ($mgmtRaw -split ',')) {
|
||||
$lvlTrim = $lvl.Trim()
|
||||
if (-not $lvlTrim) { continue }
|
||||
$lvlKey = $mgmtLevelKeys[$lvlTrim]
|
||||
$lvlValue = if ($lvlKey) { Get-LanguageString $lvlKey -IgnoreMissing } else { $null }
|
||||
if ([string]::IsNullOrEmpty($lvlValue)) { $lvlValue = $lvlTrim }
|
||||
$mgmtParts += $lvlValue
|
||||
}
|
||||
if ($mgmtParts.Count -gt 0) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.managementType') ($mgmtParts -join $Context.ObjectSeparator) 'targetedAppManagementLevels'
|
||||
}
|
||||
}
|
||||
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
|
||||
# Outlook / Edge get schema-driven translation via their ObjectInfo files
|
||||
# (port of old DocumentationCustom.psm1:2229-2260). Build a flat settings
|
||||
# object keyed by customSetting name, then walk the matching manifest.
|
||||
$appSettings = [PSCustomObject]@{}
|
||||
foreach ($setting in @($obj.customSettings)) {
|
||||
$appSettings | Add-Member -MemberType NoteProperty -Name $setting.name -Value $setting.value -Force
|
||||
}
|
||||
$objInfoDir = Join-Path $script:AppRootFolder 'Config\ObjectInfo'
|
||||
|
||||
# Unpack every packed/delimited value BEFORE the manifests read them, so the
|
||||
# rewrite also benefits the raw fall-through rows below.
|
||||
# NB Where-Object: on a property-less object .PSObject.Properties.Name is
|
||||
# $null, and @($null) is a one-element array containing $null
|
||||
foreach ($name in @($appSettings.PSObject.Properties.Name | Where-Object { $_ })) {
|
||||
$sep = $script:_mamPackedSettingSeparators[$name]
|
||||
if (-not $sep -or -not $appSettings.$name -or $appSettings.$name -isnot [string]) { continue }
|
||||
$unpacked = $appSettings.$name
|
||||
# Record separator first - doing it the other way round destroys it
|
||||
if ($sep.RecordSep) { $unpacked = $unpacked.Replace($sep.RecordSep, $Context.ObjectSeparator) }
|
||||
if ($sep.FieldSep) { $unpacked = $unpacked.Replace($sep.FieldSep, $Context.PropertySeparator) }
|
||||
$appSettings.$name = $unpacked
|
||||
}
|
||||
|
||||
# App identities differ per platform: Outlook/Edge are packageId on Android,
|
||||
# bundleId on iOS and windowsAppId on Windows. Matching only one of them
|
||||
# silently skipped the whole manifest for the other platforms.
|
||||
if (Test-MamAppTargeted $obj.Apps @('com.microsoft.office.outlook')) {
|
||||
Invoke-DocAppConfigManifest $appSettings (Join-Path $objInfoDir '#AppConfigOutlookApp.json') $Context
|
||||
}
|
||||
if (Test-MamAppTargeted $obj.Apps @('com.microsoft.msedge', 'com.microsoft.emmx', 'com.microsoft.edge')) {
|
||||
Invoke-DocAppConfigManifest $appSettings (Join-Path $objInfoDir '#AppConfigEdgeApp.json') $Context
|
||||
}
|
||||
|
||||
# Settings-catalog settings (the "Settings catalog" wizard step, used by the
|
||||
# Windows MAM flavour). Without this a policy whose entire payload lives in
|
||||
# `settings` documented as a header and nothing else.
|
||||
Invoke-DocMamSettingsCatalog $obj $Context
|
||||
|
||||
# Remaining customSettings fall through to raw key=value rows.
|
||||
$addedSettings = Get-DocumentedSettings
|
||||
$category = Get-LanguageString 'TACSettings.generalSettings'
|
||||
|
||||
foreach ($setting in $obj.customSettings) {
|
||||
if ($addedSettings | Where-Object EntityKey -EQ $setting.name) { continue }
|
||||
# Use the unpacked value when one was produced above
|
||||
$value = if ($null -ne $appSettings.PSObject.Properties[$setting.name]) { $appSettings."$($setting.name)" } else { $setting.value }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = $setting.name
|
||||
Value = $value
|
||||
EntityKey = $setting.name
|
||||
Category = $category
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Document the settings-catalog part of a MAM app configuration.
|
||||
#
|
||||
# The Managed apps wizard has a "Settings catalog" step whose values land in the
|
||||
# `settings` navigation property (Collection(deviceManagementConfigurationSetting))
|
||||
# rather than in customSettings - Windows MAM policies are entirely settings-catalog.
|
||||
# The portal renders it as its own blade ABOVE the classic Settings blade, so these
|
||||
# rows go into a table of their own (negative Order = before the settings table)
|
||||
# instead of being merged into it.
|
||||
#
|
||||
# Resolution is the SettingsCatalog provider's own code - see
|
||||
# Get-SettingsCatalogDocumentationRows. This used to be a copy of it that had
|
||||
# drifted, losing the category grouping.
|
||||
function Invoke-DocMamSettingsCatalog {
|
||||
param($Obj, [DocumentationContext]$Context)
|
||||
|
||||
$cfgSettings = @($Obj.settings)
|
||||
|
||||
$hasDefs = $false
|
||||
foreach ($s in $cfgSettings) {
|
||||
if ($s.settingDefinitions -and ($s.settingDefinitions | Measure-Object).Count -gt 0) { $hasDefs = $true; break }
|
||||
}
|
||||
|
||||
# Source-tenant-specific fetch (by policy id) - same gating as the Settings
|
||||
# Catalog provider. Exports carrying settings inline still work offline via the
|
||||
# walker's generic per-setting definition fallback.
|
||||
if (-not $hasDefs -and $Obj.Id -and -not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceAppManagement/targetedManagedAppConfigurations('$($Obj.Id)')/settings?`$expand=settingDefinitions&`$top=1000" -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context) -ODataMetadata 'minimal' -NoError
|
||||
if ($resp -and $resp.Value) { $cfgSettings = @($resp.Value) }
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to fetch settings catalog settings for app configuration $($Obj.Id)" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
if (@($cfgSettings).Count -eq 0) { return }
|
||||
|
||||
$rows = @(Get-SettingsCatalogDocumentationRows $cfgSettings $Context)
|
||||
if ($rows.Count -eq 0) { return }
|
||||
|
||||
Add-CustomTable 'SettingsCatalog' @('Name','Value') $rows -Order -100 -LanguageId 'SettingDetails.settingsCatalog'
|
||||
}
|
||||
|
||||
# MAM settings whose value packs multiple records/fields into one string.
|
||||
# RecordSep splits repeated records, FieldSep splits fields inside a record.
|
||||
$script:_mamPackedSettingSeparators = @{
|
||||
# title|url pairs, records separated by ||
|
||||
'com.microsoft.intune.mam.managedbrowser.bookmarks' = @{ RecordSep = '||'; FieldSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.managedTopSites' = @{ RecordSep = '||'; FieldSep = '|' }
|
||||
# plain pipe-separated lists
|
||||
'com.microsoft.intune.mam.managedbrowser.AllowListURLs' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.BlockListURLs' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.disabledFeatures' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.InternalPagesBlockList' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.PopupsAllowedForUrls' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.PopupsBlockedForUrls' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.FileUploadAllowedForUrls' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.FileUploadBlockedForUrls' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.NewTabPageLayout.Custom' = @{ RecordSep = '|' }
|
||||
}
|
||||
|
||||
# True when any targeted app matches one of the given app identifiers on ANY
|
||||
# platform identity (Android packageId / iOS bundleId / Windows windowsAppId).
|
||||
function Test-MamAppTargeted {
|
||||
param($Apps, [string[]]$Identifiers)
|
||||
|
||||
foreach ($app in @($Apps)) {
|
||||
$id = $app.mobileAppIdentifier
|
||||
if (-not $id) { continue }
|
||||
foreach ($candidate in @($id.packageId, $id.bundleId, $id.windowsAppId)) {
|
||||
if ($candidate -and $candidate -in $Identifiers) { return $true }
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([ManagedAppConfigDocHandler]::new())
|
||||
@@ -0,0 +1,89 @@
|
||||
# Named Location documentation handler (Country + IP variants).
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:2280 (country) + 2323 (IP).
|
||||
# Single class claims both @odata.types since they share the same BasicInfo
|
||||
# header shape and one varies only the settings.
|
||||
|
||||
class NamedLocationDocHandler : DocumentationHandlerBase {
|
||||
NamedLocationDocHandler() {
|
||||
$this.ODataTypes = @(
|
||||
'#microsoft.graph.countryNamedLocation',
|
||||
'#microsoft.graph.ipNamedLocation',
|
||||
'#microsoft.graph.compliantNetworkNamedLocation'
|
||||
)
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'AzureCA.menuItemNamedNetworks') '@odata.type'
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
switch ($obj.'@odata.type') {
|
||||
'#microsoft.graph.countryNamedLocation' { Invoke-NamedLocationCountrySettings $obj $Context }
|
||||
'#microsoft.graph.ipNamedLocation' { Invoke-NamedLocationIPSettings $obj $Context }
|
||||
'#microsoft.graph.compliantNetworkNamedLocation' { Invoke-NamedLocationCompliantNetworkSettings $obj $Context }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-NamedLocationCountrySettings {
|
||||
param($obj, [DocumentationContext]$Context)
|
||||
|
||||
$lookupSuffix = if ($obj.countryLookupMethod -eq 'clientIpAddress') { 'ip' } else { 'gps' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.NamedLocation.Form.CountryLookup.ariaLabel')
|
||||
Value = (Get-LanguageString "AzureCA.NamedLocation.Form.CountryLookup.$lookupSuffix")
|
||||
EntityKey = 'countryLookupMethod'
|
||||
})
|
||||
|
||||
$includeKey = if ($obj.includeUnknownCountriesAndRegions -eq $true) { 'Inputs.enabled' } else { 'Inputs.disabled' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.NamedLocation.Form.Include.label')
|
||||
Value = (Get-LanguageString $includeKey)
|
||||
EntityKey = 'includeUnknownCountriesAndRegions'
|
||||
})
|
||||
|
||||
$countryNames = @()
|
||||
foreach ($code in $obj.countriesAndRegions) {
|
||||
$countryNames += Get-LanguageString "AzureIAMCommon.CountryNames.countryName$($code.ToUpper())"
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.NamedLocation.Type.countries')
|
||||
Value = ($countryNames -join $Context.ObjectSeparator)
|
||||
EntityKey = 'countriesAndRegions'
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-NamedLocationIPSettings {
|
||||
param($obj, [DocumentationContext]$Context)
|
||||
|
||||
$trustedKey = if ($obj.isTrusted -eq $true) { 'Inputs.enabled' } else { 'Inputs.disabled' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.NamedLocation.Form.Trusted.label')
|
||||
Value = (Get-LanguageString $trustedKey)
|
||||
EntityKey = 'isTrusted'
|
||||
})
|
||||
|
||||
$ipList = @()
|
||||
foreach ($range in $obj.ipRanges) { $ipList += $range.cidrAddress }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.namedNetworkIpRangesTab')
|
||||
Value = ($ipList -join $Context.ObjectSeparator)
|
||||
EntityKey = 'ipRanges'
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-NamedLocationCompliantNetworkSettings {
|
||||
param($obj, [DocumentationContext]$Context)
|
||||
|
||||
# Built-in read-only location; its only meaningful setting is the trusted flag.
|
||||
$trustedKey = if ($obj.isTrusted -eq $true) { 'Inputs.enabled' } else { 'Inputs.disabled' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.NamedLocation.Form.Trusted.label')
|
||||
Value = (Get-LanguageString $trustedKey)
|
||||
EntityKey = 'isTrusted'
|
||||
})
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([NamedLocationDocHandler]::new())
|
||||
@@ -0,0 +1,95 @@
|
||||
# Notification message template documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:3660. Emits 4 branding-
|
||||
# option rows ("Show company logo/name/contact/portal link" enable/disable)
|
||||
# plus one row per localized message template with the locale name as label
|
||||
# and the subject+body as value.
|
||||
|
||||
class NotificationDocHandler : DocumentationHandlerBase {
|
||||
NotificationDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.notificationMessageTemplate')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'Titles.notifications') '@odata.type'
|
||||
|
||||
$category = Get-LanguageString 'TableHeaders.settings'
|
||||
|
||||
# brandingOptions is a comma-separated string like "includeCompanyLogo,includeCompanyName"
|
||||
# or "none". Split into a hash for membership tests.
|
||||
$brandingFlags = @{}
|
||||
if ($obj.brandingOptions) {
|
||||
foreach ($flag in $obj.brandingOptions.Split(',')) {
|
||||
$brandingFlags[$flag.Trim()] = $true
|
||||
}
|
||||
}
|
||||
|
||||
$brandingLabelMap = [ordered]@{
|
||||
'includeCompanyLogo' = 'NotificationMessage.companyLogo'
|
||||
'includeCompanyName' = 'NotificationMessage.companyName'
|
||||
'includeContactInformation' = 'NotificationMessage.companyContact'
|
||||
'includeCompanyPortalLink' = 'NotificationMessage.iwLink'
|
||||
'includeDeviceDetails' = 'NotificationMessage.deviceDetails'
|
||||
}
|
||||
|
||||
foreach ($flag in $brandingLabelMap.Keys) {
|
||||
$valueKey = if ($brandingFlags.ContainsKey($flag)) { 'BooleanActions.enable' } else { 'BooleanActions.disable' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString $brandingLabelMap[$flag])
|
||||
Value = (Get-LanguageString $valueKey)
|
||||
EntityKey = $flag
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
# Localized message templates
|
||||
$subCategory = Get-LanguageString 'NotificationMessage.listTitle'
|
||||
foreach ($template in $obj.localizedNotificationMessages) {
|
||||
$label = Get-NotificationLocaleLabel $template.locale
|
||||
if (-not $label) { continue }
|
||||
|
||||
$value = $template.subject
|
||||
if ($template.isDefault) {
|
||||
$value = $value + $Context.ObjectSeparator + (Get-LanguageString 'NotificationMessage.isDefaultLocale') + ': ' + (Get-LanguageString 'SettingDetails.trueOption')
|
||||
}
|
||||
$fullValue = $value + $Context.ObjectSeparator + $template.messageTemplate
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = $label
|
||||
Value = $fullValue
|
||||
EntityKey = $template.locale
|
||||
Category = $category
|
||||
SubCategory = $subCategory
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Localized message templates are labeled by language name. Most languages
|
||||
# pass through [cultureinfo].EnglishName.ToLower(); a handful with regional
|
||||
# splits (en-US/UK, es-ES/MX, fr-CA/FR, pt-PT/BR, zh-TW/CN, nb-* -> norwegian)
|
||||
# get a suffix. Old code at DocumentationCustom.psm1:3735-3796.
|
||||
function Get-NotificationLocaleLabel {
|
||||
param([string]$Locale)
|
||||
if (-not $Locale) { return $null }
|
||||
|
||||
$first, $second = $Locale.Split('-')
|
||||
try { $lng = ([cultureinfo]$first).EnglishName.ToLower() } catch { return $null }
|
||||
|
||||
switch ($first) {
|
||||
'en' { switch ($second) { 'US' { $lng += 'US' }; 'GB' { $lng += 'UK' } } }
|
||||
'es' { switch ($second) { 'es' { $lng += 'Spain' }; 'mx' { $lng += 'Mexico' } } }
|
||||
'fr' { switch ($second) { 'ca' { $lng += 'Canada' }; 'fr' { $lng += 'France' } } }
|
||||
'pt' { switch ($second) { 'pt' { $lng += 'Portugal' }; 'br' { $lng += 'Brazil' } } }
|
||||
'zh' { switch ($second) { 'tw' { $lng += 'Traditional' }; 'cn' { $lng += 'Simplified' } } }
|
||||
'nb' { $lng = 'norwegian' }
|
||||
}
|
||||
|
||||
return (Get-LanguageString "NotificationMessage.NotificationMessageTemplatesTab.$lng")
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([NotificationDocHandler]::new())
|
||||
@@ -0,0 +1,100 @@
|
||||
# Policy Set documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:3422. Categorizes the
|
||||
# policy-set items (apps / device configs / enrollment) into 3 sections, then
|
||||
# emits one row per item with the item's displayName and a type-specific
|
||||
# value (priority number for ordered items, AAD/AD for autopilot, etc.).
|
||||
|
||||
class PolicySetDocHandler : DocumentationHandlerBase {
|
||||
PolicySetDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.policySet')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'SettingDetails.appConfiguration') '@odata.type'
|
||||
|
||||
$sections = @(
|
||||
[PSCustomObject]@{
|
||||
Category = (Get-LanguageString 'PolicySet.appManagement')
|
||||
Types = @(
|
||||
@{ ODataType = '#microsoft.graph.mobileAppPolicySetItem'; SubKey = 'appTitle' }
|
||||
@{ ODataType = '#microsoft.graph.targetedManagedAppConfigurationPolicySetItem'; SubKey = 'appConfigurationTitle' }
|
||||
@{ ODataType = '#microsoft.graph.managedDeviceMobileAppConfigurationPolicySetItem'; SubKey = 'appConfigurationTitle' }
|
||||
@{ ODataType = '#microsoft.graph.managedAppProtectionPolicySetItem'; SubKey = 'appProtectionTitle' }
|
||||
@{ ODataType = '#microsoft.graph.iosLobAppProvisioningConfigurationPolicySetItem'; SubKey = 'iOSAppProvisioningTitle' }
|
||||
)
|
||||
}
|
||||
[PSCustomObject]@{
|
||||
Category = (Get-LanguageString 'PolicySet.deviceManagement')
|
||||
Types = @(
|
||||
@{ ODataType = '#microsoft.graph.deviceConfigurationPolicySetItem'; SubKey = 'deviceConfigurationTitle' }
|
||||
@{ ODataType = '#microsoft.graph.deviceManagementConfigurationPolicyPolicySetItem'; SubKey = 'SettingDetails.settingsCatalog' }
|
||||
@{ ODataType = '#microsoft.graph.deviceCompliancePolicyPolicySetItem'; SubKey = 'deviceComplianceTitle' }
|
||||
@{ ODataType = '#microsoft.graph.deviceManagementScriptPolicySetItem'; SubKey = 'powershellScriptTitle' }
|
||||
)
|
||||
}
|
||||
[PSCustomObject]@{
|
||||
Category = (Get-LanguageString 'PolicySet.deviceEnrollment')
|
||||
Types = @(
|
||||
@{ ODataType = '#microsoft.graph.enrollmentRestrictionsConfigurationPolicySetItem'; SubKey = 'deviceTypeRestrictionTitle' }
|
||||
@{ ODataType = '#microsoft.graph.windowsAutopilotDeploymentProfilePolicySetItem'; SubKey = 'windowsAutopilotDeploymentProfileTitle' }
|
||||
@{ ODataType = '#microsoft.graph.windows10EnrollmentCompletionPageConfigurationPolicySetItem'; SubKey = 'enrollmentStatusSettingTitle' }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
foreach ($section in $sections) {
|
||||
foreach ($subType in $section.Types) {
|
||||
foreach ($item in ($obj.items | Where-Object { $_.'@OData.Type' -eq $subType.ODataType -or $_.'@odata.type' -eq $subType.ODataType })) {
|
||||
if ($item.status -eq 'error') {
|
||||
Write-Log "Skipping missing $($subType.ODataType) type with id $($item.id). Error code: $($item.errorCode)" 2
|
||||
continue
|
||||
}
|
||||
|
||||
# SubKey is a bare key under PolicySet.* unless it already
|
||||
# carries a namespace (dotted), letting new item types reuse
|
||||
# strings that live outside the PolicySet section.
|
||||
$subKeyFull = if ($subType.SubKey -like '*.*') { $subType.SubKey } else { "PolicySet.$($subType.SubKey)" }
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = $item.displayName
|
||||
Value = (Get-PolicySetItemValue $item)
|
||||
EntityKey = $item.id
|
||||
Category = $section.Category
|
||||
SubCategory = (Get-LanguageString $subKeyFull)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PolicySetItemValue {
|
||||
param($item)
|
||||
|
||||
$odata = if ($item.PSObject.Properties['@OData.Type']) { $item.'@OData.Type' } else { $item.'@odata.type' }
|
||||
|
||||
if ($odata -in @(
|
||||
'#microsoft.graph.enrollmentRestrictionsConfigurationPolicySetItem',
|
||||
'#microsoft.graph.windows10EnrollmentCompletionPageConfigurationPolicySetItem'
|
||||
)) {
|
||||
return $item.Priority
|
||||
}
|
||||
|
||||
if ($odata -eq '#microsoft.graph.windowsAutopilotDeploymentProfilePolicySetItem') {
|
||||
if ($item.itemType -eq '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile') {
|
||||
return (Get-LanguageString 'Autopilot.DirectoryService.azureAD')
|
||||
}
|
||||
if ($item.itemType -eq '#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile') {
|
||||
return (Get-LanguageString 'Autopilot.DirectoryService.activeDirectoryAD')
|
||||
}
|
||||
}
|
||||
|
||||
# TODO phase-4-followup: other PolicySet item types as fixtures arrive
|
||||
return $null
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([PolicySetDocHandler]::new())
|
||||
@@ -0,0 +1,68 @@
|
||||
# Reusable setting (deviceManagementReusablePolicySetting) documentation
|
||||
# handler.
|
||||
#
|
||||
# Claims @odata.type='#microsoft.graph.deviceManagementReusablePolicySetting'.
|
||||
# Covers the reusable settings surfaced as policy types (currently the Linux
|
||||
# custom-compliance discovery script). The object is a name + description +
|
||||
# settingDefinitionId wrapper around a single settings-catalog setting
|
||||
# instance whose simpleSettingValue carries the payload (base64 script for
|
||||
# the discovery-script definition). No old-project documenter existed.
|
||||
|
||||
class ReusableSettingDocHandler : DocumentationHandlerBase {
|
||||
ReusableSettingDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.deviceManagementReusablePolicySetting')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
if ($PolicyObject.Name) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $PolicyObject.Name 'displayName'
|
||||
}
|
||||
$descValue = if ($obj.description) { $obj.description } else { '' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.descriptionName') $descValue 'description'
|
||||
|
||||
$category = Get-LanguageString 'TableHeaders.settings'
|
||||
|
||||
if ($obj.settingDefinitionId) {
|
||||
$definitionLabel = Get-LanguageString 'SettingDetails.settingIdName' -IgnoreMissing
|
||||
if (-not $definitionLabel) { $definitionLabel = 'Setting definition' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = $definitionLabel
|
||||
Value = [string]$obj.settingDefinitionId
|
||||
EntityKey = 'settingDefinitionId'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
$rawValue = [string]$obj.settingInstance.simpleSettingValue.value
|
||||
if ($rawValue -and -not ($Context.Options -and $Context.Options['IncludeScripts'] -eq $false)) {
|
||||
# The discovery-script definition stores the script base64-encoded;
|
||||
# fall back to the raw value for definitions that don't.
|
||||
$value = $rawValue
|
||||
try {
|
||||
$decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($rawValue))
|
||||
if ($decoded) { $value = $decoded }
|
||||
}
|
||||
catch { }
|
||||
|
||||
$valueLabel = if ([string]$obj.settingDefinitionId -like '*discoveryscript*') {
|
||||
Get-LanguageString 'ProactiveRemediations.Create.Settings.DetectionScriptMultiLineTextBox.label'
|
||||
}
|
||||
else {
|
||||
$lbl = Get-LanguageString 'SettingDetails.valueName' -IgnoreMissing
|
||||
if ($lbl) { $lbl } else { 'Value' }
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = $valueLabel
|
||||
Value = $value
|
||||
EntityKey = 'settingInstanceValue'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([ReusableSettingDocHandler]::new())
|
||||
@@ -0,0 +1,186 @@
|
||||
# Role Definition documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:4513. Resolves
|
||||
# permissions to resource/action names via /deviceManagement/resourceOperations
|
||||
# (generic schema - resolved from any connected tenant) and enriches assignments
|
||||
# with directory display names (source-tenant-specific - skipped when the source
|
||||
# tenant is unavailable, emitting raw IDs).
|
||||
|
||||
class RoleDefinitionDocHandler : DocumentationHandlerBase {
|
||||
RoleDefinitionDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.deviceAndAppManagementRoleDefinition')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'RoleAssignment.rolesMenuTitle') '@odata.type'
|
||||
|
||||
# Built-in vs custom role. isBuiltIn is true for Microsoft-supplied roles.
|
||||
if ($null -ne $obj.isBuiltIn) {
|
||||
$builtInValue = if ($obj.isBuiltIn) { Get-LanguageString 'SettingDetails.yes' } else { Get-LanguageString 'SettingDetails.no' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'DisplayRoleTypes.builtInRole') $builtInValue 'isBuiltIn'
|
||||
}
|
||||
|
||||
# --- Permissions section: resolve action IDs to resource/action names ---
|
||||
$roleResources = @()
|
||||
# resourceOperations is a GENERIC catalog (resource/action names) - same on
|
||||
# every tenant - so resolved from any connected tenant.
|
||||
if (Test-DocumentationGraphAvailable) {
|
||||
try {
|
||||
$resp = Invoke-MSGraphAPI -Url '/deviceManagement/resourceOperations'
|
||||
$roleResources = @($resp.Value)
|
||||
}
|
||||
catch {
|
||||
Write-LogError 'Failed to fetch /deviceManagement/resourceOperations for role permissions' $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
$permissionsCategory = Get-LanguageString 'Titles.permissions'
|
||||
|
||||
# Prefer the modern rolePermissions structure: union allowedResourceActions
|
||||
# across ALL rolePermissions/resourceActions. Fall back to the legacy flat
|
||||
# permissions[0].actions list when rolePermissions is absent (older payloads
|
||||
# and some built-in roles only populate the legacy list).
|
||||
$actionIds = @()
|
||||
if ($obj.rolePermissions) {
|
||||
foreach ($rolePermission in @($obj.rolePermissions)) {
|
||||
foreach ($resourceAction in @($rolePermission.resourceActions)) {
|
||||
foreach ($allowed in @($resourceAction.allowedResourceActions)) {
|
||||
if ($allowed -and $actionIds -notcontains $allowed) { $actionIds += $allowed }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($actionIds.Count -eq 0 -and $obj.permissions -and $obj.permissions[0]) {
|
||||
$actionIds = @($obj.permissions[0].actions)
|
||||
}
|
||||
|
||||
if ($roleResources.Count -gt 0 -and $actionIds.Count -gt 0) {
|
||||
# Resolved live: group resolved actions by resourceName
|
||||
$assignedActions = @()
|
||||
foreach ($id in $actionIds) {
|
||||
$r = $roleResources | Where-Object Id -EQ $id | Select-Object -First 1
|
||||
if ($r) { $assignedActions += $r }
|
||||
}
|
||||
|
||||
$byResource = $assignedActions | Select-Object resourceName -Unique | Sort-Object -Property resourceName
|
||||
foreach ($rn in $byResource.resourceName) {
|
||||
$actions = @($assignedActions | Where-Object resourceName -EQ $rn)
|
||||
$resourceId = $actions[0].resource
|
||||
$actionNames = ($actions | ForEach-Object { $_.actionName })
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = $rn
|
||||
Value = ($actionNames -join $Context.ObjectSeparator)
|
||||
EntityKey = $resourceId
|
||||
Category = $permissionsCategory
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
elseif ($actionIds.Count -gt 0) {
|
||||
# Offline: emit a single row with raw action IDs so the row count
|
||||
# is non-zero and compare-style downstream tools have something
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'Titles.permissions')
|
||||
Value = ($actionIds -join $Context.ObjectSeparator)
|
||||
EntityKey = 'actions'
|
||||
Category = $permissionsCategory
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
# --- Assignments section ---
|
||||
# roleAssignments are enriched in place (full assignment + roleScopeTags)
|
||||
# by RoleDefinitionObject's sub-resource contract during hydration, so
|
||||
# each entry already carries displayName/description/members/scopeMembers/
|
||||
# roleScopeTags — no per-assignment Graph fetch here. The
|
||||
# deviceManagement/roleAssignments/<id> API now lives only on the class.
|
||||
# Offline runs still skip this section (matching prior behavior); the
|
||||
# member display-name resolution (getByIds) is shared reference data and
|
||||
# stays online-only.
|
||||
if ($Context.SourceTenantUnavailable -or -not (Test-DocumentationGraphAvailable)) { return }
|
||||
|
||||
$assignmentsCategory = Get-LanguageString 'TableHeaders.assignments'
|
||||
foreach ($info in @($obj.roleAssignments)) {
|
||||
if (-not $info -or [string]::IsNullOrWhiteSpace([string]$info.id)) {
|
||||
Write-Log 'RoleDefinition: skipping role assignment without an id' 2
|
||||
continue
|
||||
}
|
||||
|
||||
# Resolve member + scope IDs to displayNames in one batch
|
||||
$ids = @()
|
||||
foreach ($id in @($info.members + $info.scopeMembers)) {
|
||||
if ($id -and $ids -notcontains $id) { $ids += $id }
|
||||
}
|
||||
$idInfo = @()
|
||||
if ($ids.Count -gt 0) {
|
||||
try {
|
||||
$body = @{ ids = $ids } | ConvertTo-Json
|
||||
$resp = Invoke-MSGraphAPI -Url "/directoryObjects/getByIds?`$select=displayName,id" -Content $body -Method POST
|
||||
$idInfo = @($resp.Value)
|
||||
}
|
||||
catch { Write-LogError 'Failed to resolve role-assignment member display names' $_.Exception }
|
||||
}
|
||||
|
||||
$sub = $info.displayName
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.nameName')
|
||||
Value = $info.displayName
|
||||
EntityKey = 'displayName'
|
||||
Category = $assignmentsCategory; SubCategory = $sub
|
||||
})
|
||||
if ($info.description) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.descriptionName')
|
||||
Value = $info.description
|
||||
EntityKey = 'description'
|
||||
Category = $assignmentsCategory; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
|
||||
$admins = @()
|
||||
foreach ($id in @($info.members)) {
|
||||
$resolved = $idInfo | Where-Object Id -EQ $id | Select-Object -First 1
|
||||
$admins += if ($resolved.displayName) { $resolved.displayName } else { $id }
|
||||
}
|
||||
if ($admins.Count -gt 0) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'RoleAssignment.RoleAssignmentAdmin')
|
||||
Value = ($admins -join $Context.ObjectSeparator)
|
||||
EntityKey = 'members'
|
||||
Category = $assignmentsCategory; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
|
||||
$scopeMembers = @()
|
||||
foreach ($id in @($info.scopeMembers)) {
|
||||
$resolved = $idInfo | Where-Object Id -EQ $id | Select-Object -First 1
|
||||
$scopeMembers += if ($resolved.displayName) { $resolved.displayName } else { $id }
|
||||
}
|
||||
if ($scopeMembers.Count -gt 0) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'RoleAssignment.RoleAssignmentScope')
|
||||
Value = ($scopeMembers -join $Context.ObjectSeparator)
|
||||
EntityKey = 'scopeMembers'
|
||||
Category = $assignmentsCategory; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
|
||||
$scopeTags = @($info.roleScopeTags | ForEach-Object { $_.displayName })
|
||||
if ($scopeTags.Count -gt 0) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TableHeaders.scopeTags')
|
||||
Value = ($scopeTags -join $Context.ObjectSeparator)
|
||||
EntityKey = 'scopeTags'
|
||||
Category = $assignmentsCategory; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([RoleDefinitionDocHandler]::new())
|
||||
@@ -0,0 +1,46 @@
|
||||
# Role Scope Tag documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:4882 (Invoke-
|
||||
# CDDocumentScopeTag). Claims @odata.type='#microsoft.graph.roleScopeTag'.
|
||||
#
|
||||
# Old handler accumulated per-tag rows into a cross-batch
|
||||
# $script:ObjectTypeFullTable hashtable that the output providers flushed as a
|
||||
# single consolidated "Scope Tags" table at PostProcess time. The new engine
|
||||
# already has $ctx.ObjectTypeFullTable for the same purpose, but no output
|
||||
# provider consumes it yet — until that's wired up, we just emit per-object
|
||||
# BasicInfo so each tag at least documents independently rather than being
|
||||
# dropped on the floor as NoProvider.
|
||||
#
|
||||
# Assignments are intentionally NOT translated here: Invoke-TranslateAssignments
|
||||
# is a separate ~370-LOC port (DocumentationMigration.md risk #4) that no
|
||||
# handler in the new project calls yet. When that lands, this handler can
|
||||
# trivially call it after the BasicInfo block.
|
||||
|
||||
class ScopeTagDocHandler : DocumentationHandlerBase {
|
||||
ScopeTagDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.roleScopeTag')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# Plain Name + Description rows. Scope tags don't have Platform supported
|
||||
# or Profile type rows in any old-engine path, so skip the
|
||||
# Add-BasicDefaultValues helper (which would emit blank Platform /
|
||||
# Profile rows from a missing ObjectCategories entry).
|
||||
if ($PolicyObject.Name) {
|
||||
$nameProp = if ($PolicyObject.PolicyType._NameProperty) { $PolicyObject.PolicyType._NameProperty } else { 'displayName' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $PolicyObject.Name $nameProp
|
||||
}
|
||||
|
||||
$descValue = if ($obj.description) { $obj.description } else { '' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.descriptionName') $descValue 'description'
|
||||
|
||||
if ($null -ne $obj.isBuiltIn) {
|
||||
$val = if ($obj.isBuiltIn) { Get-LanguageString 'SettingDetails.yes' } else { Get-LanguageString 'SettingDetails.no' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'RoleScopeTag.isBuiltIn') $val 'isBuiltIn'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([ScopeTagDocHandler]::new())
|
||||
@@ -0,0 +1,90 @@
|
||||
# Terms of Use (agreement) documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:2361. Note this handler
|
||||
# does NOT use Add-BasicDefaultValues — the old engine only emits Name +
|
||||
# Profile type for agreements (no Description, no Created/Modified).
|
||||
|
||||
class TermsOfUseDocHandler : DocumentationHandlerBase {
|
||||
TermsOfUseDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.agreement')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
$offLabel = Get-LanguageString 'SettingDetails.offOption'
|
||||
$onLabel = Get-LanguageString 'SettingDetails.onOption'
|
||||
|
||||
# BasicInfo: just Name + Profile type
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $obj.displayName 'displayName'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'AzureCA.menuItemTermsOfUse') '@odata.type'
|
||||
|
||||
$viewingValue = if ($obj.isViewingBeforeAcceptanceRequired) { $onLabel } else { $offLabel }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TermsOfUse.Wizard.agreementIsViewingBeforeAcceptanceRequiredLabel')
|
||||
Value = $viewingValue; Category = $null; SubCategory = $null
|
||||
EntityKey = 'isViewingBeforeAcceptanceRequired'
|
||||
})
|
||||
|
||||
$perDeviceValue = if ($obj.isPerDeviceAcceptanceRequired) { $onLabel } else { $offLabel }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TermsOfUse.Wizard.agreementIsPerDeviceAcceptanceRequiredLabel')
|
||||
Value = $perDeviceValue; Category = $null; SubCategory = $null
|
||||
EntityKey = 'isPerDeviceAcceptanceRequired'
|
||||
})
|
||||
|
||||
$expirationValue = if ($obj.termsExpiration) { $onLabel } else { $offLabel }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TermsOfUse.Wizard.isAcceptanceExpirationEnabledLabel')
|
||||
Value = $expirationValue; Category = $null; SubCategory = $null
|
||||
EntityKey = 'isAcceptanceExpirationEnabledLabel'
|
||||
})
|
||||
|
||||
# Expiration details (only when termsExpiration is set)
|
||||
if ($obj.termsExpiration.startDateTime) {
|
||||
try {
|
||||
if ($obj.termsExpiration.startDateTime -is [datetime]) {
|
||||
$tmp = if ($obj.termsExpiration.startDateTime.Kind -eq 'Utc') { $obj.termsExpiration.startDateTime.ToLocalTime() } else { $obj.termsExpiration.startDateTime }
|
||||
}
|
||||
else {
|
||||
$tmp = ([datetime]::Parse($obj.termsExpiration.startDateTime, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal)).ToLocalTime()
|
||||
}
|
||||
$startStr = $tmp.ToShortDateString()
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to parse date from string $($obj.termsExpiration.startDateTime)" 2
|
||||
$startStr = $obj.termsExpiration.startDateTime
|
||||
}
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TermsOfUse.Wizard.acceptanceExpirationStartDateTimeLabel')
|
||||
Value = $startStr; Category = $null; SubCategory = $null
|
||||
EntityKey = 'startDateTime'
|
||||
})
|
||||
|
||||
$freqValue = switch ($obj.termsExpiration.frequency) {
|
||||
'P365D' { Get-LanguageString 'TermsOfUse.AcceptanceExpirationFrequency.annually' }
|
||||
'P180D' { Get-LanguageString 'TermsOfUse.AcceptanceExpirationFrequency.biannually' }
|
||||
'P30D' { Get-LanguageString 'TermsOfUse.AcceptanceExpirationFrequency.monthly' }
|
||||
'P90D' { Get-LanguageString 'TermsOfUse.AcceptanceExpirationFrequency.quarterly' }
|
||||
default { $null }
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TermsOfUse.Wizard.acceptanceExpirationFrequencyLabel')
|
||||
Value = $freqValue; Category = $null; SubCategory = $null
|
||||
EntityKey = 'frequency'
|
||||
})
|
||||
}
|
||||
|
||||
if ($null -ne $obj.userReacceptRequiredFrequency) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TermsOfUse.Wizard.acceptanceDurationLabel')
|
||||
Value = (Get-DurationValue $obj.userReacceptRequiredFrequency)
|
||||
Category = $null; SubCategory = $null
|
||||
EntityKey = 'userReacceptRequiredFrequency'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([TermsOfUseDocHandler]::new())
|
||||
@@ -0,0 +1,400 @@
|
||||
# Windows Kiosk Configuration documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:3923 (Invoke-
|
||||
# CDDocumentWindowsKioskConfiguration). Claims @odata.type=
|
||||
# '#microsoft.graph.windowsKioskConfiguration'.
|
||||
#
|
||||
# Generic Profile/walker can't handle this type because the actual settings
|
||||
# live under nested $obj.kioskProfiles[0].appConfiguration /
|
||||
# .userAccountsConfiguration with discriminated @odata.type subtypes
|
||||
# (windowsKioskSingleWin32App, windowsKioskSingleUWPApp, windowsKioskMultipleApps,
|
||||
# windowsKioskAutologon, windowsKioskAzureADGroup/User, etc.). A handler with
|
||||
# explicit subtype dispatch is required.
|
||||
#
|
||||
# Preserves an old-engine quirk: when userAccountsConfiguration is an array of
|
||||
# mixed AAD User + AAD Group entries, PS evaluates the switch on the implicit
|
||||
# array of @odata.types and `-eq 'kioskAADUserAndGroup'` on the resulting array
|
||||
# returns the matching items (truthy in if-test). The "User logon type" row
|
||||
# then shows empty because `"SettingDetails.$($logonTypeLngId)"` interpolates
|
||||
# the array as space-joined.
|
||||
|
||||
class WindowsKioskDocHandler : DocumentationHandlerBase {
|
||||
WindowsKioskDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.windowsKioskConfiguration')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# ---- Basic info ----
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'Category.kioskConfigurationV2') '@odata.type'
|
||||
# Old engine emits a Platform row from $obj.platform. The raw payload
|
||||
# doesn't carry one for this type, so the lookup resolves to empty —
|
||||
# golden fixtures still contain the empty row, so emit it for parity.
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.platformLabel') (Get-LanguageString "Platform.$($obj.platform)") 'platform'
|
||||
|
||||
# ---- Settings ----
|
||||
$category = Get-LanguageString 'Category.kiosk'
|
||||
|
||||
$appConfig = $obj.kioskProfiles[0].appConfiguration
|
||||
$userConfig = $obj.kioskProfiles[0].userAccountsConfiguration
|
||||
|
||||
# kioskMode dispatch
|
||||
if ($appConfig.'@odata.type' -eq '#microsoft.graph.windowsKioskSingleWin32App' -or
|
||||
$appConfig.'@odata.type' -eq '#microsoft.graph.windowsKioskSingleUWPApp') {
|
||||
$kioskModeType = 'single'
|
||||
$kioskMode = Get-LanguageString 'SettingDetails.kioskSelectionSingleMode'
|
||||
}
|
||||
else {
|
||||
$kioskModeType = 'multi'
|
||||
$kioskMode = Get-LanguageString 'SettingDetails.kioskSelectionMultiMode'
|
||||
}
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskSelectionName'
|
||||
Value = $kioskMode
|
||||
EntityKey = 'kioskMode'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
# User logon type
|
||||
$logonTypeLngId = switch ($userConfig.'@odata.type') {
|
||||
'#microsoft.graph.windowsKioskAutologon' { 'kioskUserLogonTypeAutologon' }
|
||||
'#microsoft.graph.windowsKioskAzureADUser' { 'kioskAADUserAndGroup' }
|
||||
'#microsoft.graph.windowsKioskAzureADGroup' { 'kioskAADUserAndGroup' }
|
||||
'#microsoft.graph.windowsKioskLocalUser' { 'kioskAppTypeStore' }
|
||||
'#microsoft.graph.windowsKioskVisitor' { 'kioskVisitor' }
|
||||
}
|
||||
$logonType = if ($logonTypeLngId) {
|
||||
Get-LanguageString "SettingDetails.$logonTypeLngId"
|
||||
} else {
|
||||
Write-Log "Unknown kiosk user logon type. $($userConfig.'@odata.type')" 2
|
||||
$null
|
||||
}
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskSelectionUsers'
|
||||
Value = $logonType
|
||||
EntityKey = 'userAccountsConfigurationType'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
# User logon name(s)
|
||||
if ($logonTypeLngId -eq 'kioskAADUserAndGroup') {
|
||||
$aadUser = Get-LanguageString 'SettingDetails.kioskAADUser'
|
||||
$aadGroup = Get-LanguageString 'SettingDetails.kioskAADGroup'
|
||||
$users = @()
|
||||
foreach ($u in $userConfig) {
|
||||
$sep = $Context.PropertySeparator
|
||||
if ($u.'@odata.type' -eq '#microsoft.graph.windowsKioskAzureADUser') {
|
||||
$users += "$($u.userPrincipalName)$sep$aadUser"
|
||||
}
|
||||
else {
|
||||
$users += "$($u.displayName)$sep$aadGroup"
|
||||
}
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskUserAccountName'
|
||||
Value = $users -join $Context.ObjectSeparator
|
||||
EntityKey = 'userAccounts'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
elseif ($userConfig.'@odata.type' -eq '#microsoft.graph.windowsKioskLocalUser') {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskUserAccountName'
|
||||
Value = $userConfig.userName
|
||||
EntityKey = 'userName'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
# Single-app: detect underlying app type and emit type-specific rows
|
||||
if ($kioskModeType -eq 'single') {
|
||||
$uwpAppType = $null
|
||||
$appType = $null
|
||||
if ($appConfig.'@odata.type' -eq '#microsoft.graph.windowsKioskSingleWin32App') {
|
||||
$uwpAppType = 'win32App'
|
||||
$appType = Get-LanguageString 'SettingDetails.selectWin32AppForEdge86'
|
||||
}
|
||||
elseif ($appConfig.'@odata.type' -eq '#microsoft.graph.windowsKioskSingleUWPApp') {
|
||||
if ($appConfig.uwpApp.appUserModelId -like 'Microsoft.MicrosoftEdge*') {
|
||||
$uwpAppType = 'edge'
|
||||
$appType = Get-LanguageString 'SettingDetails.selectMicrosoftEdgeApp'
|
||||
}
|
||||
elseif ($appConfig.uwpApp.appUserModelId -like 'Microsoft.KioskBrowser*') {
|
||||
$uwpAppType = 'kioskBrowser'
|
||||
$appType = Get-LanguageString 'SettingDetails.selectKioskBrowserApp'
|
||||
}
|
||||
else {
|
||||
$uwpAppType = 'storeApp'
|
||||
$appType = Get-LanguageString 'SettingDetails.selectStoreApp'
|
||||
}
|
||||
}
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskApplicationType'
|
||||
Value = $appType
|
||||
EntityKey = 'kioskApplicationType'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
$edgeKioskModeType = if ($appConfig.win32App.edgeKioskType -eq 'publicBrowsing') {
|
||||
Get-LanguageString 'SettingDetails.edgeKioskModeTypePublicBrowsingInPrivate'
|
||||
} else {
|
||||
Get-LanguageString 'SettingDetails.edgeKioskModeTypeDigitalSignage'
|
||||
}
|
||||
|
||||
if ($uwpAppType -eq 'win32App') {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win32EdgeKioskUrl'
|
||||
Value = $appConfig.win32App.edgeKiosk
|
||||
EntityKey = 'edgeKiosk'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.edgeKioskModeType'
|
||||
Value = $edgeKioskModeType
|
||||
EntityKey = 'edgeKioskType'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.edgeKioskResetAfterIdleTimeInMinutesName'
|
||||
Value = $appConfig.win32App.edgeKioskIdleTimeoutMinutes
|
||||
EntityKey = 'edgeKioskIdleTimeoutMinutes'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
elseif ($uwpAppType -eq 'edge') {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.edgeKioskModeType'
|
||||
Value = $edgeKioskModeType
|
||||
EntityKey = 'edgeKioskType'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
elseif ($uwpAppType -eq 'kioskBrowser') {
|
||||
$show = Get-LanguageString 'BooleanActions.show'
|
||||
$hide = Get-LanguageString 'BooleanActions.hide'
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10KioskBrowserDefaultWebsiteName'
|
||||
Value = $obj.kioskBrowserDefaultUrl
|
||||
EntityKey = 'kioskBrowserDefaultUrl'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10KioskBrowserHomeButtonName'
|
||||
Value = if ($obj.kioskBrowserEnableHomeButton) { $show } else { $hide }
|
||||
EntityKey = 'kioskBrowserEnableHomeButton'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10KioskBrowserNavigationButtonName'
|
||||
Value = if ($obj.kioskBrowserEnableNavigationButtons) { $show } else { $hide }
|
||||
EntityKey = 'kioskBrowserEnableNavigationButtons'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10KioskBrowserEnableEndSessionButtonName'
|
||||
Value = if ($obj.kioskBrowserEnableEndSessionButton) { $show } else { $hide }
|
||||
EntityKey = 'kioskBrowserEnableEndSessionButton'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.edgeKioskResetAfterIdleTimeInMinutesName'
|
||||
Value = $obj.kioskBrowserRestartOnIdleTimeInMinutes
|
||||
EntityKey = 'kioskBrowserRestartOnIdleTimeInMinutes'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10BlockedWebsitesName'
|
||||
Value = $obj.kioskBrowserBlockedURLs -join $Context.ObjectSeparator
|
||||
EntityKey = 'kioskBrowserBlockedURLs'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10AllowedWebsitesName'
|
||||
Value = $obj.kioskBrowserBlockedUrlExceptions -join $Context.ObjectSeparator
|
||||
EntityKey = 'kioskBrowserBlockedUrlExceptions'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
elseif ($uwpAppType -eq 'storeApp') {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskModeAppStoreUrlOrManagedAppIdName'
|
||||
Value = $appConfig.uwpApp.name
|
||||
EntityKey = 'edgeKioskType'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
# Multi-app: app table + start-layout / taskbar / downloads rows
|
||||
if ($kioskModeType -eq 'multi') {
|
||||
$apps = @()
|
||||
foreach ($app in $appConfig.apps) {
|
||||
$kioskTypeLngId = switch ($app.appType) {
|
||||
'aumId' { 'kioskAppTypeAUMID' }
|
||||
'desktop' { 'kioskAppTypeDesktop' }
|
||||
'store' { 'kioskAppTypeStore' }
|
||||
default { 'kioskAppTypeUnknown' }
|
||||
}
|
||||
$kioskTileLngId = switch ($app.startLayoutTileSize) {
|
||||
'medium' { 'kioskTileMedium' }
|
||||
'small' { 'kioskTileSmall' }
|
||||
'wide' { 'kioskTileWide' }
|
||||
'large' { 'kioskTileLarge' }
|
||||
}
|
||||
$sep = $Context.PropertySeparator
|
||||
$autoLaunchStr = if ($app.autoLaunch -eq $true) { Get-LanguageString 'SettingDetails.yes' } else { Get-LanguageString 'SettingDetails.no' }
|
||||
$apps += '{0}{1}{2}{3}{4}{5}{6}' -f $app.Name, $sep, (Get-LanguageString "SettingDetails.$kioskTypeLngId"), $sep, $autoLaunchStr, $sep, (Get-LanguageString "SettingDetails.$kioskTileLngId")
|
||||
}
|
||||
|
||||
if ($apps.Count -gt 0) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskAppTableName'
|
||||
Value = $apps -join $Context.ObjectSeparator
|
||||
EntityKey = 'kioskApps'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
$altLayout = if ($null -ne $appConfig.startMenuLayoutXml) { Get-LanguageString 'SettingDetails.yes' } else { Get-LanguageString 'SettingDetails.no' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.alternativeStartLayoutName'
|
||||
Value = $altLayout
|
||||
EntityKey = 'alternativeStartLayout'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
if ($null -ne $appConfig.startMenuLayoutXml) {
|
||||
$xmlStr = try {
|
||||
[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($appConfig.startMenuLayoutXml))
|
||||
} catch { $appConfig.startMenuLayoutXml }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskStartMenuLayoutXmlName'
|
||||
Value = $xmlStr
|
||||
EntityKey = 'startMenuLayoutXml'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
$taskBar = if ($appConfig.showTaskBar) { Get-LanguageString 'BooleanActions.show' } else { Get-LanguageString 'BooleanActions.hide' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskShowTaskbarName'
|
||||
Value = $taskBar
|
||||
EntityKey = 'showTaskBar'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
$downloads = if ($appConfig.allowAccessToDownloadsFolder) { Get-LanguageString 'SettingDetails.yes' } else { Get-LanguageString 'SettingDetails.no' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10KioskAccessDownloadsFolderName'
|
||||
Value = $downloads
|
||||
EntityKey = 'allowAccessToDownloadsFolder'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
# disallowDesktopApps blocks classic Win32/desktop apps on the multi-app
|
||||
# kiosk. No scraped label exists for this toggle, so use an ASCII literal
|
||||
# (the Yes/No value is still localized).
|
||||
$disallowDesktopApps = if ($appConfig.disallowDesktopApps) { Get-LanguageString 'SettingDetails.yes' } else { Get-LanguageString 'SettingDetails.no' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = 'Block desktop (Win32) apps'
|
||||
Value = $disallowDesktopApps
|
||||
EntityKey = 'disallowDesktopApps'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
# Force-restart maintenance window
|
||||
$forceUpdateLng = if ($obj.windowsKioskForceUpdateSchedule) { 'BooleanActions.require' } else { 'BooleanActions.notConfigured' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskForceRestart'
|
||||
Value = Get-LanguageString $forceUpdateLng
|
||||
EntityKey = 'windowsKioskForceUpdateSchedule'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
if ($obj.windowsKioskForceUpdateSchedule) {
|
||||
try {
|
||||
$startDateObj = if ($obj.windowsKioskForceUpdateSchedule.startDateTime -is [DateTime]) {
|
||||
$tmp = $obj.windowsKioskForceUpdateSchedule.startDateTime
|
||||
if ($tmp.Kind -eq [DateTimeKind]::Utc) { $tmp.ToLocalTime() } else { $tmp }
|
||||
} else {
|
||||
Get-Date $obj.windowsKioskForceUpdateSchedule.startDateTime -ErrorAction Stop
|
||||
}
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskStartDateTime'
|
||||
Value = ($startDateObj.ToShortDateString() + $Context.ObjectSeparator + $startDateObj.ToShortTimeString())
|
||||
EntityKey = 'startDateTime'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
$recurrenceType = switch ($obj.windowsKioskForceUpdateSchedule.recurrence) {
|
||||
'weekly' { 'kioskWeekly' }
|
||||
'monthly' { 'kioskMonthly' }
|
||||
default { 'kioskDaily' }
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskRecurrence'
|
||||
Value = Get-LanguageString "SettingDetails.$recurrenceType"
|
||||
EntityKey = 'recurrence'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
if ($obj.windowsKioskForceUpdateSchedule.recurrence -eq 'weekly') {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.dayOfWeek'
|
||||
Value = Get-LanguageString "SettingDetails.$($obj.windowsKioskForceUpdateSchedule.dayofWeek)"
|
||||
EntityKey = 'dayofWeek'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
elseif ($obj.windowsKioskForceUpdateSchedule.recurrence -eq 'monthly') {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.dayOfMonth'
|
||||
Value = $obj.windowsKioskForceUpdateSchedule.dayofMonth
|
||||
EntityKey = 'dayofMonth'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
catch { Write-Log "Failed to format kiosk force-update schedule: $($_.Exception.Message)" 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([WindowsKioskDocHandler]::new())
|
||||
Reference in New Issue
Block a user