mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
1193 lines
55 KiB
PowerShell
1193 lines
55 KiB
PowerShell
# Shared translate primitives used by phase-3 input providers.
|
|
#
|
|
# Ported from old Documentation.psm1 (Invoke-TranslateBoolean / Option / MultiOption /
|
|
# Table / Duration + Add-PropertyInfo + Add-NotConfiguredProperty + Get-PropertyInfo
|
|
# + ObjectInfo walker customization callbacks).
|
|
#
|
|
# Key design call: keep the OLD function signatures so input providers port
|
|
# mechanically. State that used to live in $script:objectSettingsData,
|
|
# $script:currentObject, $script:CurrentSubCategory, $script:propLevel etc. now
|
|
# lives on $script:_currentDocContext, a module-local [DocumentationContext]
|
|
# pointer the engine sets via Set-CurrentDocumentationContext before invoking
|
|
# any translate primitive. This mirrors the old $script:* pattern but with a
|
|
# single typed indirection — input providers and handlers don't need to know
|
|
# the context exists.
|
|
|
|
# ---- Module-local current-context indirection ----
|
|
|
|
$script:_currentDocContext = $null
|
|
|
|
function Set-CurrentDocumentationContext {
|
|
param([DocumentationContext]$Context)
|
|
$script:_currentDocContext = $Context
|
|
# Drive Get-LanguageString off the requested documentation language so local
|
|
# (non-Graph) strings localize too, not just the Accept-Language schema fetches.
|
|
# Get-LanguageString reads $script:CurrentLanguage; without this it always used
|
|
# 'en'. The public entry points save/restore this around the run.
|
|
$script:CurrentLanguage = if ($Context -and $Context.Language) { $Context.Language } else { 'en' }
|
|
}
|
|
|
|
function Get-CurrentDocumentationContext {
|
|
if (-not $script:_currentDocContext) {
|
|
throw "No current documentation context. Call Set-CurrentDocumentationContext before invoking translate primitives."
|
|
}
|
|
return $script:_currentDocContext
|
|
}
|
|
|
|
# Accept-Language headers for the active documentation language (empty hashtable for
|
|
# en / none). Pass to Invoke-MSGraphAPI -AdditionalHeaders on any fetch that returns
|
|
# Microsoft-localized schema (ADMX definitions, categories, setting definitions) so
|
|
# the content matches the requested documentation language. Falls back to the current
|
|
# module context when no $Context is supplied.
|
|
function Get-DocAcceptLanguageHeaders {
|
|
param([DocumentationContext]$Context)
|
|
if (-not $Context) { $Context = $script:_currentDocContext }
|
|
$lang = if ($Context) { $Context.Language } else { $null }
|
|
if ($lang -and $lang -ne 'en') { return @{ 'Accept-Language' = $lang } }
|
|
return @{}
|
|
}
|
|
|
|
# ---- Property accumulator helpers (replaces old Add-PropertyInfo et al.) ----
|
|
|
|
# Add a basic-info row (name/value pair shown in the policy's header table).
|
|
# EntityKey is the source field name on the raw object (e.g. 'displayName',
|
|
# 'state', 'createdDateTime'); used by compare and other downstream tools to
|
|
# key rows independently of the localized Name. Optional; callers that don't
|
|
# care about compare can omit it.
|
|
function Add-BasicPropertyValue {
|
|
param(
|
|
[string]$Name,
|
|
[object]$Value,
|
|
[string]$EntityKey
|
|
)
|
|
$ctx = Get-CurrentDocumentationContext
|
|
$ctx.AddBasic($Name, $Value, $EntityKey)
|
|
}
|
|
|
|
# Build the rich PSCustomObject the old engine put into $script:objectSettingsData.
|
|
# Schema kept compatible with what output providers and handlers expect.
|
|
function Get-PropertyInfo {
|
|
param($Prop, $Value, $OriginalValue, $JsonValue, $TableValue)
|
|
|
|
# Get-LanguageString (Internal/LanguageString.ps1) can throw when a
|
|
# nameResourceKey path resolves to a nested object instead of a leaf
|
|
# string (e.g. a key like "WslCompliance" where WslCompliance is a
|
|
# section, not a string). Guarding both calls preserves the row but
|
|
# logs the bad key for follow-up.
|
|
if ($Prop.nameResource) {
|
|
$name = $Prop.nameResource
|
|
}
|
|
elseif ($Prop.nameResourceKey) {
|
|
$key = if ($Prop.nameResourceKey.Contains('.')) { $Prop.nameResourceKey } else { "SettingDetails.$($Prop.nameResourceKey)" }
|
|
try { $name = Get-LanguageString $key }
|
|
catch { Write-Log "Get-LanguageString '$key' failed: $($_.Exception.Message)" 2; $name = $Prop.nameResourceKey }
|
|
}
|
|
else {
|
|
$name = $Prop.entityKey
|
|
}
|
|
|
|
$description = ""
|
|
if ($Prop.descriptionResource) {
|
|
$description = $Prop.descriptionResource
|
|
}
|
|
elseif ($Prop.descriptionResourceKey) {
|
|
# Via the shared resolver so renamed keys are redirected and numeric
|
|
# metadata artifacts are skipped. See Get-ObjectInfoResourceString.
|
|
$description = Get-ObjectInfoResourceString $Prop.descriptionResourceKey
|
|
if ($null -eq $description) { $description = "" }
|
|
}
|
|
|
|
$categoryStr = $null
|
|
if ($Prop.category) {
|
|
# New project's category helper (old code used Get-Category — renamed)
|
|
try { $categoryStr = Get-PolicyObjectCategoryString $Prop.category }
|
|
catch { Write-Log "Get-PolicyObjectCategoryString '$($Prop.category)' failed: $($_.Exception.Message)" 2 }
|
|
}
|
|
|
|
if (-not $JsonValue -and $null -ne $OriginalValue -and "$OriginalValue" -ne "") {
|
|
$JsonValue = $OriginalValue | ConvertTo-Json -Depth 50 -Compress
|
|
}
|
|
|
|
$defValue = $null
|
|
if ($Prop.emptyValueResourceKey) {
|
|
try { $defValue = Get-LanguageString $Prop.emptyValueResourceKey }
|
|
catch { Write-Log "Get-LanguageString '$($Prop.emptyValueResourceKey)' failed: $($_.Exception.Message)" 2 }
|
|
}
|
|
else {
|
|
$defValue = $Prop.defaultValue
|
|
}
|
|
|
|
$ctx = Get-CurrentDocumentationContext
|
|
return [PSCustomObject]@{
|
|
Name = $name
|
|
Description = $description
|
|
Value = $Value
|
|
Category = $categoryStr
|
|
SubCategory = $ctx.CurrentSubCategory
|
|
Property = $Prop.entityKey
|
|
DataType = $Prop.dataType
|
|
RawValue = $OriginalValue
|
|
RawJsonValue = $JsonValue
|
|
DefaultValue = $defValue
|
|
FullValueTable = $TableValue
|
|
UnconfiguredValue = $Prop.unconfiguredValue
|
|
AlwaysAddValue = $Prop.alwaysAddValue -eq $true
|
|
Enabled = $Prop.Enabled
|
|
EntityKey = $Prop.EntityKey
|
|
# PropLevel uses -1 internally as a "reset on next recursion" sentinel
|
|
# (set by dataType 8 sub-headers / dataType 5 groups). That sentinel must
|
|
# never reach a row: a negative level renders as padding-left:0px, pushing
|
|
# the setting name left of its category header. Clamp so top-level settings
|
|
# align with the header (default cell padding) and only real child
|
|
# settings (positive levels) indent.
|
|
Level = [Math]::Max(0, [int]$ctx.PropLevel)
|
|
}
|
|
}
|
|
|
|
# Add a translated property to the context. Routes to BasicInfo when the
|
|
# prop is in the synthetic category 1000 (header info); otherwise to FilteredSettings.
|
|
function Add-PropertyInfo {
|
|
param($Prop, $Value, $OriginalValue, $JsonValue, $TableValue)
|
|
|
|
if ($Prop.Category -eq "1000") {
|
|
$name = if ($Prop.nameResource) {
|
|
$Prop.nameResource
|
|
}
|
|
elseif ($Prop.nameResourceKey) {
|
|
$key = if ($Prop.nameResourceKey.Contains('.')) { $Prop.nameResourceKey } else { "SettingDetails.$($Prop.nameResourceKey)" }
|
|
try { Get-LanguageString $key } catch { Write-Log "Get-LanguageString '$key' failed: $($_.Exception.Message)" 2; $Prop.nameResourceKey }
|
|
}
|
|
else { $Prop.entityKey }
|
|
Add-BasicPropertyValue $name $Value
|
|
return
|
|
}
|
|
|
|
$info = Get-PropertyInfo $Prop $Value $OriginalValue $JsonValue $TableValue
|
|
$ctx = Get-CurrentDocumentationContext
|
|
$ctx.AddSetting($info)
|
|
|
|
Invoke-CustomPostAddValue $Prop
|
|
}
|
|
|
|
# Pre-built PSCustomObject path (rarer — used when a custom handler has already
|
|
# constructed the row in the result shape).
|
|
function Add-PropertyInfoObject {
|
|
param($PropInfo)
|
|
if (-not $PropInfo) { return }
|
|
$ctx = Get-CurrentDocumentationContext
|
|
$ctx.AddSetting($PropInfo)
|
|
}
|
|
|
|
# Custom handlers (Conditional Access, PolicySet, etc.) build their own row
|
|
# PSCustomObjects directly (no $prop metadata indirection) and add them via this
|
|
# helper. Old code: Add-CustomSettingObject in Documentation.psm1:4041.
|
|
function Add-CustomSettingObject {
|
|
param($SettingsObj)
|
|
if (-not $SettingsObj) { return }
|
|
$ctx = Get-CurrentDocumentationContext
|
|
$ctx.AddSetting($SettingsObj)
|
|
}
|
|
|
|
# Adds the standard "Created" / "Modified" BasicInfo rows. Reads dates through
|
|
# the wrapper's `.Created` / `.LastModified` script properties (which already
|
|
# handle the lastModifiedDateTime/modifiedDateTime fallback). Old code:
|
|
# Add-BasicAdditionalValues in Documentation.psm1:697.
|
|
function Add-BasicAdditionalValues {
|
|
param($PolicyObject)
|
|
# Per-handler private helpers (e.g. Invoke-CADocBasicInfo) don't always
|
|
# have $PolicyObject in scope when they fan out to Add-BasicAdditionalValues;
|
|
# the engine has already pinned the wrapper to the context so we just
|
|
# read it back. Explicit param still wins for callers that have it.
|
|
if (-not $PolicyObject) { $PolicyObject = (Get-CurrentDocumentationContext).PolicyObject }
|
|
if (-not $PolicyObject) { return }
|
|
$obj = $PolicyObject.JsonObject
|
|
|
|
if ($PolicyObject.Created -is [datetime]) {
|
|
Add-BasicPropertyValue (Get-LanguageString 'Inputs.createdDateTime') (Format-BasicDateValue $PolicyObject.Created) 'createdDateTime'
|
|
}
|
|
if ($PolicyObject.LastModified -is [datetime]) {
|
|
# Note: old engine uses TableHeaders.lastModified (= "Last modified") here,
|
|
# NOT Inputs.lastModifiedDateTime (which resolves to "Last updated Time").
|
|
# Entity-key reflects which of the two underlying fields was used so
|
|
# downstream tools that key by entityKey still see the right name.
|
|
$modKey = if ($obj.lastModifiedDateTime) { 'lastModifiedDateTime' } else { 'modifiedDateTime' }
|
|
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.lastModified') (Format-BasicDateValue $PolicyObject.LastModified) $modKey
|
|
}
|
|
|
|
# Version row (when the object has a numeric version, e.g. enrollment
|
|
# restriction configurations). Truthy check skips 0 (system-created defaults).
|
|
if ($obj.version) {
|
|
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.eDPPolicyAppsListVersionName') $obj.version 'version'
|
|
}
|
|
}
|
|
|
|
# Formats a [datetime] the way the old engine did: long date + long time, in
|
|
# local time. Kept separate so any downstream change to the date format only
|
|
# touches one place.
|
|
function Format-BasicDateValue {
|
|
param([datetime]$Value)
|
|
$tmp = if ($Value.Kind -eq 'Utc') { $Value.ToLocalTime() } else { $Value }
|
|
return "$($tmp.ToLongDateString()) $($tmp.ToLongTimeString())"
|
|
}
|
|
|
|
# Emits the conventional BasicInfo header rows that almost every handler / input
|
|
# provider starts with: Name, Description, plus Platform-supported / Profile-type
|
|
# from the ObjectCategories.json lookup. Old code: Add-BasicDefaultValues at
|
|
# Documentation.psm1:607.
|
|
function Add-BasicDefaultValues {
|
|
param($PolicyObject, [string]$ProfileTypeName, [string[]]$SkipProperties = @())
|
|
# Context fallback (see Add-BasicAdditionalValues for rationale).
|
|
if (-not $PolicyObject) { $PolicyObject = (Get-CurrentDocumentationContext).PolicyObject }
|
|
if (-not $PolicyObject) { return }
|
|
$obj = $PolicyObject.JsonObject
|
|
|
|
# Name: routed through the wrapper's GetName(), which resolves the per-type
|
|
# _NameProperty (e.g. `fileName` for ADMX, `displayName` for most). Avoids
|
|
# the displayName-vs-name guessing the helper used to do.
|
|
$nameProp = if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType._NameProperty) {
|
|
$PolicyObject.PolicyType._NameProperty
|
|
} else { 'displayName' }
|
|
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $PolicyObject.Name $nameProp
|
|
|
|
# Always emit a Description row — old engine includes one with value=""
|
|
# even for objects (e.g. Named Locations) that don't have a description
|
|
# property in their raw Graph payload at all.
|
|
$descValue = if ($obj.description) { $obj.description } else { '' }
|
|
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.descriptionName') $descValue 'description'
|
|
|
|
# Platform-supported + Profile-type rows from Config/ObjectCategories.json
|
|
# lookup. If the type isn't catalogued (e.g. assignment filters, named
|
|
# locations), these stay silent and the handler emits its own Profile-type
|
|
# row without duplication.
|
|
$odata = $obj.'@odata.type'
|
|
if ($odata -and (Get-Command Get-PolicyObjectCategoryInfo -ErrorAction SilentlyContinue)) {
|
|
try {
|
|
$objInfo = Get-PolicyObjectCategoryInfo $odata
|
|
if ($objInfo) {
|
|
if ($objInfo.PlatformLanguageId -and -not $SkipProperties.Contains('platformSupported')) {
|
|
$platformType = Get-LanguageString "Platform.$($objInfo.PlatformLanguageId)"
|
|
if ($platformType) {
|
|
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.platformSupported') $platformType 'platformSupported'
|
|
}
|
|
}
|
|
$profileType = if ($ProfileTypeName) { $ProfileTypeName }
|
|
elseif ($objInfo.PolicyType) { Get-LanguageString "ConfigurationTypes.$($objInfo.PolicyType)" }
|
|
else { $null }
|
|
if ($profileType -and -not $SkipProperties.Contains('@odata.type')) {
|
|
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') $profileType '@odata.type'
|
|
}
|
|
}
|
|
} catch { }
|
|
}
|
|
}
|
|
|
|
# Resolves roleScopeTagIds / roleScopeTags arrays to display names and appends
|
|
# them as a single comma-joined BasicInfo row. Cache lives on the context
|
|
# ($ctx.ScopeTags); populated lazily on first call. Old code: Add-ScopeTagStrings
|
|
# at Documentation.psm1:797, with caching at Documentation.psm1:214.
|
|
function Add-ScopeTagStrings {
|
|
param($Obj)
|
|
if (-not $Obj) { return }
|
|
|
|
$prop = $null
|
|
if ($Obj.PSObject.Properties['roleScopeTagIds']) { $prop = 'roleScopeTagIds' }
|
|
elseif ($Obj.PSObject.Properties['roleScopeTags']) { $prop = 'roleScopeTags' }
|
|
if (-not $prop) { return }
|
|
|
|
$ids = @($Obj.$prop)
|
|
if ($ids.Count -eq 0) { return }
|
|
|
|
$ctx = Get-CurrentDocumentationContext
|
|
|
|
# Lazy cache population — source-tenant-specific scope tags; skipped when the
|
|
# source tenant is unavailable (another tenant's tags would be wrong).
|
|
if ((-not $ctx.ScopeTags -or $ctx.ScopeTags.Count -eq 0) -and -not $ctx.SourceTenantUnavailable) {
|
|
if (Test-DocumentationGraphAvailable) {
|
|
try {
|
|
$resp = Invoke-MSGraphAPI -Url '/deviceManagement/roleScopeTags'
|
|
if ($resp.Value) { $ctx.ScopeTags = $resp.Value }
|
|
}
|
|
catch {
|
|
Write-LogError 'Failed to load scope tags for documentation' $_.Exception
|
|
}
|
|
}
|
|
}
|
|
|
|
$names = foreach ($id in $ids) {
|
|
if ($id -eq '0') {
|
|
Get-LanguageString 'SettingDetails.default'
|
|
}
|
|
elseif ($ctx.ScopeTags) {
|
|
$tag = $ctx.ScopeTags | Where-Object Id -eq $id | Select-Object -First 1
|
|
if ($tag -and $tag.displayName) { $tag.displayName } else { $id }
|
|
}
|
|
else {
|
|
$id
|
|
}
|
|
}
|
|
|
|
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.scopeTags') (($names) -join $ctx.ObjectSeparator) 'roleScopeTagIds'
|
|
}
|
|
|
|
# Translate $obj.assignments into structured Assignment rows on the context.
|
|
# Port of old Invoke-TranslateAssignments (Documentation.psm1:3610, ~370 LOC).
|
|
# Trimmed for v1:
|
|
# - Generic assignments: full parity (group/include/exclude/filter rows)
|
|
# - App (mobileApp) assignments: Group + Intent + Filter; per-intent extra
|
|
# settings (restart, install-time, notifications, deliveryOptimization, ...)
|
|
# deferred — those add ~150 LOC of per-prop translation and need their own
|
|
# fixture coverage before they're worth porting. Apps still document
|
|
# correctly, just without the extra-settings block.
|
|
#
|
|
# Honors $ctx.Options.ExcludeAssignments (default false).
|
|
# Offline-safe: group/filter resolution falls back to ID when no Graph access.
|
|
# Resolve Entra group IDs -> displayName via /directoryObjects/getByIds, chunked
|
|
# at 1000 (the Graph batch-getByIds limit). Populates the run cache
|
|
# Context.GroupNamesById, storing $null for IDs the directory did not return so
|
|
# unresolved IDs are not retried on later policies (negative cache, matching the
|
|
# assignment-filter pattern). Only IDs not already cached are queried, so the
|
|
# opt-in run preload and the per-object lazy path share one cache without
|
|
# double-fetching. Returns a hashtable of the requested IDs that resolved to a
|
|
# non-empty name.
|
|
function Resolve-DocumentationGroupNames {
|
|
param(
|
|
[string[]]$GroupIds,
|
|
[DocumentationContext]$Context
|
|
)
|
|
$out = @{}
|
|
if (-not $Context -or -not $GroupIds) { return $out }
|
|
|
|
$toQuery = @($GroupIds | Where-Object { $_ -and -not $Context.GroupNamesById.ContainsKey($_) } | Select-Object -Unique)
|
|
for ($i = 0; $i -lt $toQuery.Count; $i += 1000) {
|
|
$end = [Math]::Min($i + 999, $toQuery.Count - 1)
|
|
$chunk = @($toQuery[$i..$end])
|
|
$returned = @{}
|
|
try {
|
|
$body = (@{ ids = $chunk; types = @('group') } | ConvertTo-Json -Compress)
|
|
$resp = Invoke-MSGraphAPI -Url '/directoryObjects/getByIds?$select=displayName,id' -Content $body -HttpMethod 'POST'
|
|
foreach ($g in @($resp.value)) {
|
|
if ($g.id) { $returned[[string]$g.id] = [string]$g.displayName }
|
|
}
|
|
}
|
|
catch {
|
|
Write-LogError 'Failed to resolve assignment groups via /directoryObjects/getByIds' $_.Exception
|
|
# Leave this chunk uncached so a transient failure can be retried
|
|
# later (do NOT negative-cache on error).
|
|
continue
|
|
}
|
|
foreach ($id in $chunk) {
|
|
if ($returned.ContainsKey($id) -and $returned[$id]) {
|
|
$Context.GroupNamesById[$id] = $returned[$id]
|
|
}
|
|
else {
|
|
$Context.GroupNamesById[$id] = $null # looked up, not found - don't retry
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($id in $GroupIds) {
|
|
if ($Context.GroupNamesById.ContainsKey($id) -and $Context.GroupNamesById[$id]) {
|
|
$out[$id] = $Context.GroupNamesById[$id]
|
|
}
|
|
}
|
|
return $out
|
|
}
|
|
|
|
function Add-AssignmentsForObject {
|
|
param($Obj)
|
|
if (-not $Obj) { return }
|
|
|
|
$ctx = Get-CurrentDocumentationContext
|
|
if ($ctx.Options.ExcludeAssignments) { return }
|
|
|
|
$assignments = @($Obj.assignments)
|
|
if ($assignments.Count -eq 0) { return }
|
|
|
|
# Collect unique IDs so we resolve in batch (one Graph call, not N).
|
|
# The all-zeros GUID is Intune's "no filter" sentinel; never a real
|
|
# filter id. Anything that doesn't shape like a GUID would produce a
|
|
# malformed URL on the per-id resolution path — drop those too so the
|
|
# batch lookup below is always safe to interpolate.
|
|
$groupIds = @($assignments.target.groupId | Where-Object { $_ } | Select-Object -Unique)
|
|
$filterIds = @($assignments.target.deviceAndAppManagementAssignmentFilterId |
|
|
Where-Object {
|
|
(Test-AssignmentFilterDefined $_) -and
|
|
($_ -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
|
|
} |
|
|
Select-Object -Unique)
|
|
|
|
# ---- Resolve groups ----
|
|
# Cache hits first: the opt-in run preload (Sync-DocumentationGroupPreload)
|
|
# and earlier policies this run populate $ctx.GroupNamesById. Only IDs still
|
|
# missing hit Graph, and Resolve-DocumentationGroupNames caches the result so
|
|
# later policies referencing the same group are free. When the preload ran,
|
|
# every ID is already a hit and no per-object Graph call happens here.
|
|
$groupNamesById = @{}
|
|
if ($groupIds.Count -gt 0) {
|
|
$missing = @($groupIds | Where-Object { -not $ctx.GroupNamesById.ContainsKey($_) })
|
|
foreach ($gid in $groupIds) {
|
|
if ($ctx.GroupNamesById.ContainsKey($gid) -and $ctx.GroupNamesById[$gid]) {
|
|
$groupNamesById[$gid] = $ctx.GroupNamesById[$gid]
|
|
}
|
|
}
|
|
if ($missing.Count -gt 0 -and -not $ctx.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
|
$resolved = Resolve-DocumentationGroupNames -GroupIds $missing -Context $ctx
|
|
foreach ($gid in $resolved.Keys) { $groupNamesById[$gid] = $resolved[$gid] }
|
|
}
|
|
# Offline fallback: leave IDs unresolved — output providers display the
|
|
# raw GUID, matching old engine behavior when the migration table is
|
|
# unavailable.
|
|
}
|
|
|
|
# ---- Resolve filters ----
|
|
# Normally the run prefetch (Initialize-DocumentationRunPrefetch) has
|
|
# already seeded $ctx.FilterNamesById — from the login-time tenant
|
|
# dependency cache or its batched tenant-wide GET — and set FiltersLoaded.
|
|
# This lazy fallback covers the single-policy Get-GraphDocumentation path.
|
|
# One tenant-wide list GET, not per-id: the Intune beta endpoint doesn't
|
|
# honour `$filter=id in (...)` (the StatelessPayloadLinkingService proxy
|
|
# 400s the OData syntax). Same shape as Get-CDAllTenantApps below.
|
|
if ($filterIds.Count -gt 0 -and -not $ctx.SourceTenantUnavailable -and -not $ctx.FiltersLoaded -and (Test-DocumentationGraphAvailable)) {
|
|
try {
|
|
$resp = Invoke-MSGraphAPI -Url '/deviceManagement/assignmentFilters?$select=id,displayName&$top=999' -ODataMetadata 'minimal'
|
|
foreach ($f in @($resp.value)) {
|
|
if ($f.id) { $ctx.FilterNamesById[$f.id] = $f.displayName }
|
|
}
|
|
}
|
|
catch {
|
|
Write-LogError 'Failed to resolve assignment filters' $_.Exception
|
|
}
|
|
# Stamp regardless of success so a tenant-wide 5xx doesn't trigger a
|
|
# retry per policy (cache acts as both lookup AND negative-cache).
|
|
$ctx.FiltersLoaded = $true
|
|
}
|
|
$filterNamesById = @{}
|
|
foreach ($id in $filterIds) {
|
|
if ($ctx.FilterNamesById.ContainsKey($id) -and $ctx.FilterNamesById[$id]) {
|
|
$filterNamesById[$id] = $ctx.FilterNamesById[$id]
|
|
}
|
|
}
|
|
|
|
# ---- Translate each assignment ----
|
|
$includeLabel = Get-LanguageString 'TableHeaders.includedGroups'
|
|
$excludeLabel = Get-LanguageString 'TableHeaders.excludedGroups'
|
|
$noFilter = Get-LanguageString 'AssignmentFilters.noFilters'
|
|
$filterInc = Get-LanguageString 'SettingDetails.include'
|
|
$filterExc = Get-LanguageString 'SettingDetails.exclude'
|
|
|
|
$included = @()
|
|
$excluded = @()
|
|
$appRows = @()
|
|
|
|
foreach ($assignment in $assignments) {
|
|
if (-not $assignment -or -not $assignment.target) { continue }
|
|
|
|
$isAppAssignment = ($assignment.PSObject.Properties['intent'] -and
|
|
$assignment.PSObject.Properties['settings'])
|
|
|
|
$groupMode = if ($assignment.target.'@odata.type' -eq '#microsoft.graph.exclusionGroupAssignmentTarget') {
|
|
'exclude'
|
|
} else { 'include' }
|
|
|
|
$groupName = switch ($assignment.target.'@odata.type') {
|
|
'#microsoft.graph.allDevicesAssignmentTarget' { Get-LanguageString 'SettingDetails.allDevices' }
|
|
'#microsoft.graph.allLicensedUsersAssignmentTarget' { Get-LanguageString 'SettingDetails.allUsers' }
|
|
default {
|
|
$gid = [string]$assignment.target.groupId
|
|
if ($gid -and $groupNamesById.ContainsKey($gid)) { $groupNamesById[$gid] }
|
|
elseif ($gid) { $gid }
|
|
else { 'unknown' }
|
|
}
|
|
}
|
|
|
|
$filterName = $null; $filterMode = $null
|
|
if ($assignment.target.PSObject.Properties['deviceAndAppManagementAssignmentFilterId']) {
|
|
$filterName = $noFilter; $filterMode = $noFilter
|
|
$fid = [string]$assignment.target.deviceAndAppManagementAssignmentFilterId
|
|
# Test-AssignmentFilterDefined, not a truthy check: the all-zeros sentinel
|
|
# means "no filter" and keeps the $noFilter labels. Reporting it verbatim
|
|
# printed a filter named 00000000-0000-0000-0000-000000000000 in Exclude
|
|
# mode on assignments that cannot carry a filter at all.
|
|
if (Test-AssignmentFilterDefined $fid) {
|
|
$filterName = if ($filterNamesById.ContainsKey($fid)) { $filterNamesById[$fid] } else { $fid }
|
|
$filterMode = if ($assignment.target.deviceAndAppManagementAssignmentFilterType -eq 'include') { $filterInc } else { $filterExc }
|
|
}
|
|
}
|
|
|
|
if ($isAppAssignment) {
|
|
$row = @{
|
|
Group = $groupName
|
|
GroupMode = Get-LanguageString "AssignmentAction.$groupMode"
|
|
Category = Get-LanguageString "InstallIntent.$($assignment.intent)"
|
|
RawIntent = $assignment.intent
|
|
Type = 'AppAssignment'
|
|
RawJsonValue = ($assignment | ConvertTo-Json -Depth 50 -Compress)
|
|
}
|
|
if ($groupMode -eq 'include') {
|
|
$row['Filter'] = $filterName
|
|
$row['FilterMode'] = $filterMode
|
|
|
|
# Per-intent extra settings (port of old Documentation.psm1:3776-3895).
|
|
# Only Included app assignments carry these. Output providers turn each
|
|
# key of the ordered 'Settings' dictionary into a column.
|
|
if ($null -ne $assignment.settings) {
|
|
$settingsProps = [ordered]@{}
|
|
# Ordered to match the Intune portal's assignment columns (iOS VPP):
|
|
# VPN, License type, Prevent automatic app updates, Uninstall on device
|
|
# removal, Install as removable, Prevent iCloud app backup. Non-portal /
|
|
# other-app-type keys follow.
|
|
foreach ($settingProp in @('useDeviceContext','vpnConfigurationId','useDeviceLicensing','preventAutoAppUpdate',
|
|
'uninstallOnDeviceRemoval','isRemovable','preventManagedAppBackup',
|
|
'androidManagedStoreAppTrackIds','deliveryOptimizationPriority',
|
|
'installTimeSettings','notifications','restartSettings')) {
|
|
if (-not ($assignment.settings.PSObject.Properties | Where-Object Name -EQ $settingProp)) { continue }
|
|
$sVal = $assignment.settings.$settingProp
|
|
|
|
if ($settingProp -eq 'useDeviceLicensing') {
|
|
$value = if ($sVal -eq $true) { Get-LanguageString 'SettingDetails.licenseTypeDevice' }
|
|
else { Get-LanguageString 'SettingDetails.licenseTypeUser' }
|
|
}
|
|
elseif ($settingProp -eq 'restartSettings') {
|
|
if ($null -eq $sVal) { $value = Get-LanguageString 'SettingDetails.disabledOption' }
|
|
else {
|
|
$arr = @()
|
|
$arr += "$(Get-LanguageString 'Assignment.RestartGracePeriod.durationInMinutes')=$($sVal.gracePeriodInMinutes)"
|
|
$arr += "$(Get-LanguageString 'Assignment.RestartGracePeriod.countdownDialog')=$($sVal.countdownDisplayBeforeRestartInMinutes)"
|
|
if ($null -eq $sVal.restartNotificationSnoozeDurationInMinutes) {
|
|
$arr += "$(Get-LanguageString 'Assignment.RestartGracePeriod.allowSnooze')=$(Get-LanguageString 'SettingDetails.no')"
|
|
} else {
|
|
$arr += "$(Get-LanguageString 'Assignment.RestartGracePeriod.allowSnooze')=$(Get-LanguageString 'SettingDetails.yes')"
|
|
$arr += "$(Get-LanguageString 'Assignment.RestartGracePeriod.snoozeDurationInMinutes')=$($sVal.restartNotificationSnoozeDurationInMinutes)"
|
|
}
|
|
$value = $arr -join $ctx.ObjectSeparator
|
|
}
|
|
}
|
|
elseif ($settingProp -eq 'notifications') {
|
|
$value = Get-LanguageString "AppResources.AssignmentToast.$sVal"
|
|
if (-not $value) { $value = $sVal }
|
|
}
|
|
elseif ($settingProp -eq 'installTimeSettings') {
|
|
$asap = Get-LanguageString 'Assignment.SoftwareInstallationTime.defaultTime'
|
|
$startValue = $asap; $value = $asap
|
|
if ($sVal) {
|
|
if ($sVal.startDateTime) {
|
|
$instTime = Get-Date $sVal.startDateTime
|
|
if ($sVal.useLocalTime -eq $false) { $instTime = $instTime.AddHours(($instTime.ToUniversalTime() - $instTime).Hours) }
|
|
$startValue = "$($instTime.ToShortDateString()) $($instTime.ToShortTimeString())"
|
|
}
|
|
if ($sVal.deadlineDateTime) {
|
|
$endTime = Get-Date $sVal.deadlineDateTime
|
|
if ($sVal.useLocalTime -eq $false) { $endTime = $endTime.AddHours(($endTime.ToUniversalTime() - $endTime).Hours) }
|
|
$value = "$($endTime.ToShortDateString()) $($endTime.ToShortTimeString())"
|
|
}
|
|
}
|
|
$settingsProps['startTimeColumnLabel'] = $startValue
|
|
if ($assignment.intent -eq 'available') { continue } # no deadline column on available
|
|
}
|
|
elseif ($settingProp -eq 'deliveryOptimizationPriority') {
|
|
$tmpStr = Get-LanguageString 'AppResources.DeliveryOptimizationPriority.displayText'
|
|
$tmpType = if ($sVal -ne 'foreground') { Get-LanguageString 'AppResources.DeliveryOptimizationPriority.backgroundNormal' }
|
|
else { Get-LanguageString 'AppResources.DeliveryOptimizationPriority.foreground' }
|
|
$value = $tmpStr -f $tmpType
|
|
}
|
|
elseif ("$sVal" -eq 'notConfigured') { $value = Get-LanguageString 'BooleanActions.notConfigured' }
|
|
else { $value = $sVal }
|
|
|
|
$settingsProps[$settingProp] = $value
|
|
}
|
|
if ($settingsProps.Count -gt 0) { $row['Settings'] = $settingsProps }
|
|
}
|
|
}
|
|
$appRows += [PSCustomObject]$row
|
|
}
|
|
else {
|
|
$row = [PSCustomObject]@{
|
|
GroupMode = if ($groupMode -eq 'include') { $includeLabel } else { $excludeLabel }
|
|
Group = $groupName
|
|
Type = 'GenericAssignment'
|
|
Category = if ($groupMode -eq 'include') { $includeLabel } else { $excludeLabel }
|
|
}
|
|
if ($groupMode -eq 'include' -and $null -ne $filterMode) {
|
|
$row | Add-Member -MemberType NoteProperty -Name 'Filter' -Value $filterName -Force
|
|
$row | Add-Member -MemberType NoteProperty -Name 'FilterMode' -Value $filterMode -Force
|
|
}
|
|
if ($groupMode -eq 'include') { $included += $row } else { $excluded += $row }
|
|
}
|
|
}
|
|
|
|
foreach ($r in $included) { $ctx.AddAssignment($r) }
|
|
foreach ($r in $excluded) { $ctx.AddAssignment($r) }
|
|
|
|
# Sort app rows by intent order: required -> available -> availableWithoutEnrollment -> uninstall
|
|
foreach ($intent in @('required','available','availableWithoutEnrollment','uninstall')) {
|
|
foreach ($r in ($appRows | Where-Object RawIntent -EQ $intent)) {
|
|
$ctx.AddAssignment($r)
|
|
}
|
|
}
|
|
}
|
|
|
|
# Track properties that couldn't be translated (booleanActions miss, unknown option,
|
|
# notConfigured fallback). Used downstream for diagnostic output.
|
|
function Add-NotConfiguredProperty {
|
|
param($Prop)
|
|
$ctx = Get-CurrentDocumentationContext
|
|
$ctx.UnconfiguredProperties += $Prop
|
|
}
|
|
|
|
# ---- ObjectInfo walker customization callbacks ----
|
|
#
|
|
# Schema-driven Manifest/Profile translation uses a separate customizer registry.
|
|
# Whole-object handlers cannot provide these callbacks because registering one
|
|
# bypasses the ObjectInfo input providers entirely.
|
|
|
|
# ---- AppConfig / CustomOMAUri helpers ----
|
|
|
|
# Infers a Platform.* language id from the object's @odata.type. Used by
|
|
# handlers that need a "Platform: iOS" / "Mac" / "Windows10" basic info row.
|
|
# Faithful port of old Documentation.psm1:879. Note: the old code's
|
|
# Contains("androidForWork") branch is unreachable (it's a case-sensitive
|
|
# match against an already-lowercased string); preserving for parity.
|
|
function Get-ObjectPlatformFromType {
|
|
param($Obj)
|
|
if (-not $Obj.'@OData.Type' -and -not $Obj.'@odata.type') { return $null }
|
|
$t = (($Obj.'@OData.Type'),($Obj.'@odata.type') | Where-Object { $_ })[0].ToLower()
|
|
|
|
if ($t.Contains('ios')) { 'iOS' }
|
|
elseif ($t.Contains('mac')) { 'Mac' }
|
|
elseif ($t.Contains('windowsphone')){ 'WindowsPhone' }
|
|
elseif ($t.Contains('windows') -or $t.Contains('win32') -or $t.Contains('mirosoftstore')) { 'Windows10' }
|
|
elseif ($t.Contains('androidforwork')) { 'androidForWork' }
|
|
elseif ($t.Contains('android')) { 'Android' }
|
|
else { $null }
|
|
}
|
|
|
|
# Returns the in-progress per-object settings buffer. Handlers use this to
|
|
# avoid double-adding settings the ObjectInfo walker has already emitted
|
|
# (matches old Documentation.psm1:386 Get-DocumentedSettings).
|
|
function Get-DocumentedSettings {
|
|
$ctx = Get-CurrentDocumentationContext
|
|
return @($ctx.SettingsData)
|
|
}
|
|
|
|
# App-catalog lookup for AppConfig handlers. Live: /deviceAppManagement/mobileApps
|
|
# (paged). Offline: returns empty list so handlers fall through to raw app IDs.
|
|
# Cached on $ctx via a hashtable extension on the singleton.
|
|
function Get-CDAllTenantApps {
|
|
$ctx = Get-CurrentDocumentationContext
|
|
if (-not $ctx.PSObject.Properties['_AllTenantApps']) {
|
|
$ctx | Add-Member -MemberType NoteProperty -Name '_AllTenantApps' -Value $null -Force
|
|
}
|
|
if ($ctx._AllTenantApps) { return $ctx._AllTenantApps }
|
|
if ($ctx.SourceTenantUnavailable -or -not (Test-DocumentationGraphAvailable)) {
|
|
$ctx._AllTenantApps = @()
|
|
return $ctx._AllTenantApps
|
|
}
|
|
try {
|
|
$resp = Invoke-MSGraphAPI -Url '/deviceAppManagement/mobileApps?$select=displayName,id&$top=999'
|
|
$ctx._AllTenantApps = @($resp.Value)
|
|
} catch {
|
|
Write-LogError 'Failed to load /deviceAppManagement/mobileApps' $_.Exception
|
|
$ctx._AllTenantApps = @()
|
|
}
|
|
return $ctx._AllTenantApps
|
|
}
|
|
|
|
# Notification message templates, for enrollment notifications. An enrollment
|
|
# notification policy stores only a template id per channel; the subject and body
|
|
# a reader actually wants are the template's localized messages. $expand pulls
|
|
# every template with its messages in ONE request, cached for the whole run, so
|
|
# documenting N notification policies costs one call rather than 2N.
|
|
# Offline: empty list, and the caller renders the channel without its text.
|
|
#
|
|
# Two things about the cache. It is keyed by the policy's OWN token, because with
|
|
# two tenants signed in the active provider may belong to the other one, and its
|
|
# templates would document this policy's messages as "Not configured". And it
|
|
# lives for one run only: Reset-DocumentationTenantLookups drops it at the start
|
|
# of each bulk run and whenever a run flips to offline, so a message edited
|
|
# between two runs is read fresh and an offline run never answers from what a
|
|
# live one cached. The offline guard runs before the cache is consulted for the
|
|
# same reason.
|
|
function Get-CDNotificationMessageTemplates {
|
|
$ctx = Get-CurrentDocumentationContext
|
|
if ($ctx.SourceTenantUnavailable -or -not (Test-DocumentationGraphAvailable)) { return @() }
|
|
|
|
if (-not $ctx.PSObject.Properties['_NotificationMessageTemplates']) {
|
|
$ctx | Add-Member -MemberType NoteProperty -Name '_NotificationMessageTemplates' -Value $null -Force
|
|
}
|
|
if ($ctx._NotificationMessageTemplates -isnot [hashtable]) { $ctx._NotificationMessageTemplates = @{} }
|
|
|
|
# A policy loaded from a live tenant carries the token it came from; one
|
|
# loaded from a file carries 0 and a test fixture nothing, and both mean
|
|
# "the default token". The default is whatever is signed in NOW, and that
|
|
# changes when the user switches tenants or accounts between two
|
|
# single-object calls, which keep this cache. So the key is the CONNECTION
|
|
# the token resolves to - tenant, account and app, plus the resolved id -
|
|
# never the bare token id: a "0" entry would go on answering for whatever
|
|
# happened to be default when it was filled. Nor the tenant alone: two
|
|
# accounts in one tenant see different templates once scope tags apply, and
|
|
# an app-only token and a user token need not be authorized alike.
|
|
$tokenId = 0
|
|
if ($ctx.PolicyObject -and $null -ne $ctx.PolicyObject._TokenId) { $tokenId = [int]$ctx.PolicyObject._TokenId }
|
|
$tokenInfo = $null
|
|
try { $tokenInfo = Get-OperationTokenInfo $tokenId } catch { }
|
|
if ($tokenInfo -and [int]$tokenInfo.Id -gt 0) { $tokenId = [int]$tokenInfo.Id }
|
|
$cacheKey = if ($tokenInfo) { "$tokenId|$($tokenInfo.TenantID)|$($tokenInfo.User)|$($tokenInfo.ClientID)" } else { "token:$tokenId" }
|
|
if ($ctx._NotificationMessageTemplates.ContainsKey($cacheKey)) { return $ctx._NotificationMessageTemplates[$cacheKey] }
|
|
|
|
try {
|
|
$params = @{ Url = '/deviceManagement/notificationMessageTemplates?$expand=localizedNotificationMessages' }
|
|
if ($tokenId -gt 0) { $params.TokenId = $tokenId }
|
|
$resp = Invoke-MSGraphAPI @params
|
|
$ctx._NotificationMessageTemplates[$cacheKey] = @($resp.Value)
|
|
}
|
|
catch {
|
|
Write-LogError 'Failed to load /deviceManagement/notificationMessageTemplates' $_.Exception
|
|
$ctx._NotificationMessageTemplates[$cacheKey] = @()
|
|
}
|
|
return $ctx._NotificationMessageTemplates[$cacheKey]
|
|
}
|
|
|
|
# Managed-app catalog lookup. The managedAppStatuses('managedAppList') endpoint
|
|
# is the same source used by the old documentation provider. Offline callers can
|
|
# pre-seed _AllManagedApps on the context from exported fixture data.
|
|
function Get-CDAllManagedApps {
|
|
$ctx = Get-CurrentDocumentationContext
|
|
if (-not $ctx.PSObject.Properties['_AllManagedApps']) {
|
|
$ctx | Add-Member -MemberType NoteProperty -Name '_AllManagedApps' -Value $null -Force
|
|
}
|
|
if ($ctx._AllManagedApps) { return $ctx._AllManagedApps }
|
|
if ($ctx.SourceTenantUnavailable -or -not (Test-DocumentationGraphAvailable)) {
|
|
$ctx._AllManagedApps = @()
|
|
return $ctx._AllManagedApps
|
|
}
|
|
try {
|
|
$resp = Invoke-MSGraphAPI -Url "/deviceAppManagement/managedAppStatuses('managedAppList')"
|
|
$ctx._AllManagedApps = @($resp.content.appList)
|
|
}
|
|
catch {
|
|
Write-LogError "Failed to load managed app catalog" $_.Exception
|
|
$ctx._AllManagedApps = @()
|
|
}
|
|
return $ctx._AllManagedApps
|
|
}
|
|
|
|
# Given a policy's $obj.Apps array, partitions display names into
|
|
# (customApps, publishedApps) based on Microsoft-first-party flag.
|
|
# Old code: DocumentationCustom.psm1:207. Offline returns ([],[]).
|
|
function Get-CDMobileApps {
|
|
param($Apps)
|
|
if (-not $Apps) { return @(@(), @()) }
|
|
$managed = Get-CDAllManagedApps
|
|
$customApps = @()
|
|
$publishedApps = @()
|
|
foreach ($tmpApp in $Apps) {
|
|
$appObj = $managed | Where-Object {
|
|
$pkgMatch = $tmpApp.mobileAppIdentifier.packageId -and $_.appIdentifier.packageId -eq $tmpApp.mobileAppIdentifier.packageId
|
|
$bundMatch = $tmpApp.mobileAppIdentifier.bundleId -and $_.appIdentifier.bundleId -eq $tmpApp.mobileAppIdentifier.bundleId
|
|
$winMatch = $tmpApp.mobileAppIdentifier.windowsAppId -and $_.appIdentifier.windowsAppId -eq $tmpApp.mobileAppIdentifier.windowsAppId
|
|
$typeMatch = $_.appIdentifier.'@odata.type' -eq $tmpApp.mobileAppIdentifier.'@odata.type'
|
|
($pkgMatch -or $bundMatch -or $winMatch) -and $typeMatch
|
|
} | Select-Object -First 1
|
|
if ($appObj -and $appObj.isFirstParty) { $publishedApps += $appObj.displayName }
|
|
elseif ($appObj) { $customApps += $appObj.displayName }
|
|
else {
|
|
# Not in the managed-app catalog - that IS what a custom app is (a
|
|
# hand-entered bundle/package id). Previously these were dropped, so the
|
|
# "Custom apps" row always rendered empty.
|
|
$rawId = @($tmpApp.mobileAppIdentifier.packageId, $tmpApp.mobileAppIdentifier.bundleId, $tmpApp.mobileAppIdentifier.windowsAppId) | Where-Object { $_ } | Select-Object -First 1
|
|
if ($rawId) { $customApps += $rawId }
|
|
}
|
|
}
|
|
return @(,$customApps + ,$publishedApps)
|
|
}
|
|
|
|
# Append a row collection to $ctx.CustomTables (e.g. AdditionalSettings or
|
|
# Permissions tables emitted by Android app-config handlers). Maps to old
|
|
# Documentation.psm1 Add-CustomTable.
|
|
function Add-CustomTable {
|
|
param(
|
|
[string]$TableId,
|
|
[string[]]$Columns = @('Name','Value'),
|
|
$Values,
|
|
[int]$Order = 100,
|
|
[string]$LanguageId = ''
|
|
)
|
|
$ctx = Get-CurrentDocumentationContext
|
|
$ctx.AddCustomTable([PSCustomObject]@{
|
|
Id = $TableId
|
|
Columns = $Columns
|
|
Values = $Values
|
|
LanguageId = $LanguageId
|
|
Order = $Order
|
|
})
|
|
}
|
|
|
|
function Get-CustomChildObject {
|
|
param($Obj, $Prop)
|
|
return Invoke-DocumentationObjectInfoGetChildObject $Obj $Prop
|
|
}
|
|
|
|
function Get-CustomPropertyObject {
|
|
param($Obj, $Prop)
|
|
return Invoke-DocumentationObjectInfoGetPropertyObject $Obj $Prop
|
|
}
|
|
|
|
function Get-CustomProfileValue {
|
|
param($Obj, $Prop)
|
|
return Invoke-DocumentationObjectInfoGetProfileValue $Obj $Prop
|
|
}
|
|
|
|
function Invoke-CustomPostAddValue {
|
|
param($Prop)
|
|
Invoke-DocumentationObjectInfoPostAddValue $Prop
|
|
}
|
|
|
|
function Invoke-ChildSections {
|
|
param($Obj, $SectionObject)
|
|
# Recurse into both .Children and .ChildSettings via the walker
|
|
# (Invoke-TranslateSection in ObjectInfoWalker.ps1). Old code at
|
|
# Documentation.psm1:2859. The walker preserves propLevel via
|
|
# $script:_currentSectionParent so nesting depth tracks correctly.
|
|
$ctx = Get-CurrentDocumentationContext
|
|
$childObj = Get-CustomChildObject $Obj $SectionObject
|
|
|
|
$savedLevel = $ctx.PropLevel
|
|
if (Get-Command Invoke-TranslateSection -ErrorAction SilentlyContinue) {
|
|
if ($SectionObject.Children) {
|
|
Invoke-TranslateSection $childObj $SectionObject.Children $null -Parent $SectionObject
|
|
}
|
|
$ctx.PropLevel = $savedLevel
|
|
if ($SectionObject.ChildSettings) {
|
|
Invoke-TranslateSection $childObj $SectionObject.ChildSettings $null -Parent $SectionObject
|
|
}
|
|
}
|
|
}
|
|
|
|
# ---- Translate primitives ----
|
|
|
|
function Invoke-TranslateBoolean {
|
|
param($Obj, $Prop)
|
|
|
|
$propValue = $Obj."$($Prop.entityKey)"
|
|
if ($null -eq $propValue) { $propValue = $Prop.unconfiguredValue }
|
|
|
|
if ($propValue -is [string] -and ($propValue -eq 'true' -or $propValue -eq 'false')) {
|
|
$propValue = [bool]::Parse($propValue)
|
|
}
|
|
|
|
if ("$propValue" -eq 'notConfigured') {
|
|
return (Get-LanguageString 'BooleanActions.notConfigured')
|
|
}
|
|
|
|
# Booleans where $true maps to a single language id
|
|
$singleTrue = @{
|
|
0 = 'BooleanActions.allow'; 1 = 'BooleanActions.require'
|
|
2 = 'BooleanActions.enable'; 3 = 'BooleanActions.block'
|
|
4 = 'BooleanActions.configured'; 5 = 'BooleanActions.disable'
|
|
6 = 'BooleanActions.limit'; 7 = 'BooleanActions.show'
|
|
8 = 'BooleanActions.hide'; 9 = 'BooleanActions.yes'
|
|
}
|
|
if ($singleTrue.ContainsKey([int]$Prop.booleanActions) -and $propValue -eq $true) {
|
|
return (Get-LanguageString $singleTrue[[int]$Prop.booleanActions])
|
|
}
|
|
|
|
# Booleans where both $true and $false map to language ids (true-id / false-id)
|
|
$pairs = @{
|
|
100 = @('BooleanActions.block', 'BooleanActions.allow')
|
|
101 = @('BooleanActions.require', 'SettingDetails.notRequired')
|
|
102 = @('BooleanActions.enable', 'BooleanActions.disable')
|
|
107 = @('BooleanActions.show', 'BooleanActions.hide')
|
|
108 = @('BooleanActions.hide', 'BooleanActions.show')
|
|
109 = @('BooleanActions.yes', 'SettingDetails.no')
|
|
110 = @('SettingDetails.no', 'BooleanActions.yes')
|
|
120 = @('SettingDetails.onOption', 'SettingDetails.offOption')
|
|
200 = @('BooleanActions.allow', 'BooleanActions.block')
|
|
201 = @('SettingDetails.notRequired','BooleanActions.require')
|
|
220 = @('SettingDetails.offOption', 'SettingDetails.onOption')
|
|
}
|
|
if ($pairs.ContainsKey([int]$Prop.booleanActions)) {
|
|
$ids = $pairs[[int]$Prop.booleanActions]
|
|
$id = if ($propValue) { $ids[0] } else { $ids[1] }
|
|
return (Get-LanguageString $id)
|
|
}
|
|
|
|
Add-NotConfiguredProperty $Prop
|
|
return (Get-LanguageString 'BooleanActions.notConfigured')
|
|
}
|
|
|
|
function Invoke-TranslateOption {
|
|
param($Obj, $Prop, [switch]$SkipOptionChildren, $PropValue = $null)
|
|
|
|
if ($null -eq $PropValue) {
|
|
$PropValue = $Obj."$($Prop.entityKey)"
|
|
}
|
|
|
|
# Quirk preserved from old code: defenderSecurityCenterDisableRansomwareUI as $true
|
|
# gets coerced to "blockOption" so the option lookup below can match it.
|
|
if ($Obj.defenderSecurityCenterDisableRansomwareUI -eq $true) {
|
|
$Obj.defenderSecurityCenterDisableRansomwareUI = 'blockOption'
|
|
}
|
|
|
|
foreach ($option in $Prop.options) {
|
|
if ("$PropValue" -ne "$($option.Value)") { continue }
|
|
|
|
$optionValue = $null
|
|
if ($option.nameResource) {
|
|
# $option, not $Prop. The old engine read $prop.nameResource here
|
|
# (Documentation.psm1:3214), so a literal option label rendered the
|
|
# PROPERTY's name as its value - "Token type : Token type". The two
|
|
# sibling translators (MultiOption, MultiOptionBoolean) always read
|
|
# $option.nameResource, which is what makes the typo visible as a bug
|
|
# rather than a convention. No shipped manifest set nameResource on a
|
|
# single-option property, so nothing rendered before this changes.
|
|
$optionValue = $option.nameResource
|
|
}
|
|
elseif ($option.displayText) {
|
|
$optionValue = $option.displayText
|
|
}
|
|
elseif ($option.nameResourceKey) {
|
|
if ($option.nameResourceKey -eq 'notConfigured') {
|
|
Add-NotConfiguredProperty $Prop
|
|
}
|
|
$key = if ($option.nameResourceKey.Contains('.')) { $option.nameResourceKey } else { "SettingDetails.$($option.nameResourceKey)" }
|
|
$optionValue = Get-LanguageString $key
|
|
}
|
|
else {
|
|
$optionValue = $option.Value
|
|
}
|
|
|
|
# Return shape preserved from old code (some callers consume the pair)
|
|
@{ Option = $option; Value = $optionValue }
|
|
|
|
Add-PropertyInfo $Prop $optionValue $PropValue
|
|
|
|
if (-not $SkipOptionChildren) {
|
|
Invoke-ChildSections (Get-CustomChildObject $Obj $Prop) $option
|
|
}
|
|
break
|
|
}
|
|
|
|
if ($PropValue -is [bool] -and $Prop.ChildSettings.Count -gt 0) {
|
|
Write-Log "Child properties for boolean $($Prop.EntityKey) value=$PropValue added. Disabled items might be included." 2
|
|
}
|
|
|
|
Invoke-ChildSections $Obj $Prop
|
|
}
|
|
|
|
function Invoke-TranslateMultiOption {
|
|
param($Obj, $Prop)
|
|
|
|
$propValues = $null
|
|
if ($Obj.PSObject.Properties.Name -contains $Prop.entityKey) {
|
|
$propValues = $Obj."$($Prop.entityKey)"
|
|
if ($propValues -is [string]) { $propValues = $propValues.Split(',') }
|
|
}
|
|
elseif ($Prop.entityKey -like '*List') {
|
|
$tmpProp = $Prop.entityKey.Substring(0, $Prop.entityKey.Length - 4)
|
|
$propValues = $Obj.$tmpProp
|
|
}
|
|
|
|
$ctx = Get-CurrentDocumentationContext
|
|
$selectedValues = @()
|
|
foreach ($propValue in $propValues) {
|
|
$option = $Prop.Options | Where-Object Value -EQ $propValue
|
|
if (-not $option) { continue }
|
|
|
|
if ($option.nameResource) {
|
|
$selectedValues += $option.nameResource
|
|
}
|
|
else {
|
|
$key = if ($option.nameResourceKey.Contains('.')) { $option.nameResourceKey } else { "SettingDetails.$($option.nameResourceKey)" }
|
|
$selectedValues += (Get-LanguageString $key)
|
|
}
|
|
}
|
|
|
|
if ($selectedValues.Count -gt 0) {
|
|
return ($selectedValues -join $ctx.PropertySeparator)
|
|
}
|
|
|
|
Add-NotConfiguredProperty $Prop
|
|
return (Get-LanguageString 'BooleanActions.notConfigured')
|
|
}
|
|
|
|
function Invoke-TranslateMultiOptionBoolean {
|
|
param($Obj, $Prop, $SelectedValue = $true)
|
|
|
|
$propObj = $Obj."$($Prop.entityKey)"
|
|
if (-not $propObj) { return (Get-LanguageString 'BooleanActions.notConfigured') }
|
|
|
|
$ctx = Get-CurrentDocumentationContext
|
|
$selectedValues = @()
|
|
foreach ($propValue in $propObj.PSObject.Properties.Name) {
|
|
if ($propObj.$propValue -isnot [bool]) { continue }
|
|
$option = $Prop.options | Where-Object value -EQ $propValue
|
|
if (-not $option) { continue }
|
|
|
|
if ($propObj.$propValue -ne $SelectedValue) { continue }
|
|
|
|
if ($option.nameResource) {
|
|
$selectedValues += $option.nameResource
|
|
}
|
|
else {
|
|
$key = if ($option.nameResourceKey.Contains('.')) { $option.nameResourceKey } else { "SettingDetails.$($option.nameResourceKey)" }
|
|
$selectedValues += (Get-LanguageString $key)
|
|
}
|
|
}
|
|
|
|
if ($selectedValues.Count -gt 0) {
|
|
return ($selectedValues -join $ctx.PropertySeparator)
|
|
}
|
|
return (Get-LanguageString 'BooleanActions.notConfigured')
|
|
}
|
|
|
|
function Invoke-TranslateTable {
|
|
param($Obj, $Prop)
|
|
|
|
$propValue = if ($Prop.entityKey -eq '.') { $Obj } else { $Obj."$($Prop.entityKey)" }
|
|
$ctx = Get-CurrentDocumentationContext
|
|
|
|
$items = @()
|
|
$itemFullValue = @()
|
|
foreach ($item in $propValue) {
|
|
$itemValues = @()
|
|
$htFullPropInfo = [ordered]@{}
|
|
|
|
foreach ($column in $Prop.Columns) {
|
|
if ($column.metadata.entityKey -eq 'unusedForSingleItems') {
|
|
$itemValues += $item
|
|
}
|
|
elseif ($column.metadata.entityKey -eq $Prop.entityKey -and ($Prop.Columns | Measure-Object).Count -eq 1) {
|
|
# Self-referencing single-column tables
|
|
$itemValues += $item
|
|
}
|
|
elseif (($Prop.Columns | Measure-Object).Count -eq 1 -and `
|
|
$null -eq $item."$($column.metadata.entityKey)" -and `
|
|
$null -eq $Obj."$($column.metadata.entityKey)" -and `
|
|
$item -is [string]) {
|
|
# String-list with declared (but empty) entity key
|
|
$itemValues += $item
|
|
}
|
|
else {
|
|
$itemTmpVal = $null
|
|
if ($item.PSObject.Properties | Where-Object Name -Like $column.metadata.entityKey) {
|
|
$itemTmpVal = $item."$($column.metadata.entityKey)"
|
|
}
|
|
else {
|
|
$itemTmpVal = $Obj."$($column.metadata.entityKey)"
|
|
}
|
|
$itemValues += $itemTmpVal
|
|
|
|
if ($Prop.Columns.Count -gt 1) {
|
|
if ($column.metadata.nameResourceKey) {
|
|
$key = if ($column.metadata.nameResourceKey.Contains('.')) { $column.metadata.nameResourceKey } else { "SettingDetails.$($column.metadata.nameResourceKey)" }
|
|
$colName = Get-LanguageString $key
|
|
if (-not $colName) { $colName = $column.metadata.entityKey }
|
|
$htFullPropInfo.Add($colName, ($itemTmpVal -join $ctx.PropertySeparator))
|
|
}
|
|
else {
|
|
$nameForLog = if ($Prop.nameResourceKey) { $Prop.nameResourceKey } else { $Prop.entityKey }
|
|
Write-Log "Property $nameForLog does not have nameResourceKey on one of the columns" 2
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($htFullPropInfo.Count -gt 0) {
|
|
$itemFullValue += [PSCustomObject]$htFullPropInfo
|
|
}
|
|
|
|
$sep = if ($Prop.separator) { $Prop.separator } else { $ctx.PropertySeparator }
|
|
$items += ($itemValues -join $sep)
|
|
}
|
|
|
|
if ($items.Count -gt 0) {
|
|
$params = @{}
|
|
if ($itemFullValue.Count -gt 0) { $params['TableValue'] = $itemFullValue }
|
|
|
|
if ((-not $Prop.nameResourceKey -or $Prop.nameResourceKey -eq 'Empty') -and $Prop.columns[0].metadata.nameResourceKey) {
|
|
Add-PropertyInfo $Prop.columns[0].metadata ($items -join $ctx.ObjectSeparator) $propValue @params
|
|
}
|
|
else {
|
|
Add-PropertyInfo $Prop ($items -join $ctx.ObjectSeparator) $propValue @params
|
|
}
|
|
}
|
|
else {
|
|
if ((-not $Prop.nameResourceKey -or $Prop.nameResourceKey -eq 'Empty') -and $Prop.Columns[0].metadata.nameResourceKey) {
|
|
Add-PropertyInfo $Prop.Columns[0].metadata $null
|
|
}
|
|
else {
|
|
Add-PropertyInfo $Prop $null
|
|
}
|
|
}
|
|
|
|
Invoke-ChildSections $Obj $Prop
|
|
}
|
|
|
|
function Invoke-TranslateDuration {
|
|
param($Obj, $Prop)
|
|
$raw = $Obj."$($Prop.entityKey)"
|
|
# Optional manifest hint ("durationUnit": "minutes"|"days"|"hours"|"seconds"):
|
|
# render the ISO8601 duration in the unit the row label promises. Graph stores
|
|
# normalized durations (30240 minutes round-trips as P21D), so the legacy
|
|
# first-component fallback below would show "21" under a "(minutes)" label.
|
|
if ($Prop.durationUnit -and $raw) {
|
|
$ts = Get-DurationValue $raw -ReturnTimeSpan
|
|
if ($ts -is [timespan]) {
|
|
$value = switch ([string]$Prop.durationUnit) {
|
|
'days' { $ts.TotalDays }
|
|
'hours' { $ts.TotalHours }
|
|
'seconds' { $ts.TotalSeconds }
|
|
default { $ts.TotalMinutes }
|
|
}
|
|
return [string][math]::Round($value)
|
|
}
|
|
}
|
|
Get-DurationValue $raw
|
|
}
|
|
|
|
function Get-DurationValue {
|
|
param($DurationValue, [switch]$ReturnTimeSpan)
|
|
|
|
if (-not $DurationValue -or -not $DurationValue.StartsWith('P')) { return "0" }
|
|
|
|
# Ported from old Documentation.psm1:3461. Without -ReturnTimeSpan the function
|
|
# returns the bare digits of the FIRST non-empty component (e.g. "P70D" -> "70",
|
|
# "P1Y" -> "1", "PT15M" -> "15"). With -ReturnTimeSpan it accumulates
|
|
# years+days+hours+minutes+seconds into a real TimeSpan.
|
|
#
|
|
# NB: the old code's $DurationValue.Split($arr) is a no-op - a [string[]] passed
|
|
# to String.Split() without StringSplitOptions binds to no char[] overload and
|
|
# returns the whole string, so the function always returned the raw ISO8601
|
|
# value ("P70D" rendered verbatim under a "(days)" label). Split explicitly on
|
|
# [string[]] with StringSplitOptions::None so empty inter-delimiter segments are
|
|
# kept and the $values indices stay aligned with the delimiter loop below.
|
|
$arr = @('P','T','Y','D','H','M','S')
|
|
$values = $DurationValue.Split([string[]]$arr, [System.StringSplitOptions]::None)
|
|
|
|
$years = 0; $days = 0; $hours = 0; $minutes = 0; $seconds = 0
|
|
$i = 0
|
|
foreach ($tmp in $arr) {
|
|
if ($DurationValue.Contains($tmp)) {
|
|
if ($ReturnTimeSpan) {
|
|
switch ($tmp) {
|
|
'Y' { $years = [int]$values[$i] }
|
|
'D' { $days = [int]$values[$i] }
|
|
'H' { $hours = [int]$values[$i] }
|
|
'M' { $minutes = [int]$values[$i] }
|
|
'S' { $seconds = [int]$values[$i] }
|
|
}
|
|
}
|
|
elseif (-not [string]::IsNullOrEmpty($values[$i])) {
|
|
return $values[$i]
|
|
}
|
|
$i++
|
|
}
|
|
}
|
|
|
|
if ($ReturnTimeSpan) {
|
|
$days += ($years * 365) # approximate; matches old code
|
|
return [timespan]::new($days, $hours, $minutes, $seconds)
|
|
}
|
|
return "0"
|
|
}
|