Files

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"
}