mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
IntuneManagement 4.0.0-beta1
This commit is contained in:
@@ -0,0 +1,589 @@
|
||||
# Atlassian (Confluence Storage Format) output provider.
|
||||
#
|
||||
# Emits Confluence-compatible XHTML for pasting into a Confluence page editor
|
||||
# (Rich Text -> Source view) or POSTing via the Confluence REST API as
|
||||
# `representation=storage`. Structurally identical to the HTML provider
|
||||
# (BasicInfo / FilteredSettings / ComplianceActions / ApplicabilityRules /
|
||||
# Assignments / CustomTables + a per-run table of contents), only the emitted
|
||||
# markup differs: no CSS embed, no <HTML>/<body> wrapper, code and long-text
|
||||
# blocks use Confluence macros (<ac:structured-macro name='code'|'expand'>).
|
||||
#
|
||||
# Heading anchors: every heading carries id='<anchor>' plus an inline `anchor`
|
||||
# macro of the same name, and the table of contents links that name. Anchors are
|
||||
# positional - 'section-N' for every heading, numbered in emission order across
|
||||
# the whole run (including headings kept out of the TOC),
|
||||
# so a name is never reused. 'table-N' is reserved for the -ToT caption form,
|
||||
# which no call site in this provider currently uses - table captions are plain
|
||||
# level-6 headings here, as in the HTML provider. The id= attribute is a
|
||||
# documented contract for consumers that parse the generated file before it is
|
||||
# published (Confluence itself discards the attribute); it always equals the
|
||||
# macro name. Changing the naming scheme is a breaking change for those
|
||||
# consumers.
|
||||
#
|
||||
# Options (via $Options.Outputs.atlassian):
|
||||
# AtlassianDocumentName - target file path. Supports Expand-FileName
|
||||
# tokens (%MyDocuments%, %Organization%, %Date%,
|
||||
# %DateTime%). Default: %MyDocuments%\%Organization%-%Date%.html
|
||||
# AtlassianDocumentFileType - 'Full' (single file) or 'Object' (one file per
|
||||
# policy + a TOC index file). Default: 'Full'.
|
||||
# AtlassianTitleProperty - H1 title of the index page. Default: 'Intune documentation'.
|
||||
# AtlassianOpenFile - After writing, launch the file with the OS
|
||||
# default handler. Set $false for CI runs.
|
||||
# Default: $true.
|
||||
|
||||
function Invoke-InitializeAtlassianOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "Atlassian"
|
||||
Value = "atlassian"
|
||||
# Path metadata (see DocumentationOutputHTML.ps1 header comment). Drives
|
||||
# the "default output folder" inference on the bulk-doc form, whose
|
||||
# Atlassian options panel mirrors the HTML one minus the CSS row.
|
||||
PrimaryPathOption = "AtlassianDocumentName"
|
||||
PathIsFolder = $false
|
||||
PreProcess = { Invoke-AtlassianPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-AtlassianNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-AtlassianNewObjectType @args }
|
||||
Process = { Invoke-AtlassianProcessItem @args }
|
||||
PostProcess = { Invoke-AtlassianPostProcessItems @args }
|
||||
ProcessAllObjects = { Invoke-AtlassianProcessAllObjects @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-AtlassianPreProcessItems {
|
||||
$script:atlSectionAnchors = @()
|
||||
$script:atlTotAnchors = @()
|
||||
# Anchor numbering is deliberately NOT derived from the anchor lists above:
|
||||
# a -SkipTOC heading is emitted (and needs an anchor) without being listed,
|
||||
# so a list-derived number would be handed out twice. See Add-AtlassianHeader.
|
||||
$script:atlSectionCount = 0
|
||||
$script:atlTotCount = 0
|
||||
$script:atlBody = $null
|
||||
$script:atlCurrentItemFileName = $null
|
||||
|
||||
$fileName = Get-DocumentationOutputOption atlassian "AtlassianDocumentName" ""
|
||||
if (-not $fileName) { $fileName = "%MyDocuments%\%Organization%-%Date%.html" }
|
||||
$fileName = Expand-FileName $fileName
|
||||
|
||||
$script:atlOutFile = $fileName
|
||||
$script:atlDocumentPath = [IO.Path]::GetDirectoryName($fileName)
|
||||
$script:atlOutputType = Get-DocumentationOutputOption atlassian "AtlassianDocumentFileType" "Full"
|
||||
|
||||
if ($script:atlOutputType -eq "Object") {
|
||||
Write-Log "Atlassian: document one file for each object + index file"
|
||||
}
|
||||
else {
|
||||
Write-Log "Atlassian: document one single file for all objects"
|
||||
$script:atlOutputType = "Full"
|
||||
$script:atlBody = [System.Text.StringBuilder]::new()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-AtlassianPostProcessItems {
|
||||
$userName = $null
|
||||
$mail = ""
|
||||
$me = Get-CurrentUser
|
||||
if ($me) {
|
||||
if ($me.givenName -and $me.surname) {
|
||||
$userName = "$($me.givenName) $($me.surname)"
|
||||
}
|
||||
else {
|
||||
$userName = $me.displayName
|
||||
}
|
||||
if ($me.mail) { $mail = " ($($me.mail))" }
|
||||
}
|
||||
|
||||
$orgName = Get-CurrentOrganizationName
|
||||
|
||||
$title = Get-DocumentationOutputOption atlassian "AtlassianTitleProperty" "Intune documentation"
|
||||
if (-not $title) { $title = "Intune documentation" }
|
||||
|
||||
# Escaped for the same reason heading text is: an '&' in the tenant's
|
||||
# organization name or in the configured title would make the document body
|
||||
# invalid XML, and Confluence rejects the upload of the whole page.
|
||||
$content = [System.Text.StringBuilder]::new()
|
||||
[void]$content.AppendLine("<h1>$(Get-AtlassianXmlText $title)</h1>")
|
||||
|
||||
if (-not ((Get-DocumentationOption "SkipDocumentInfo" $false) -eq $true)) {
|
||||
if ($orgName) { [void]$content.AppendLine("Organization: $(Get-AtlassianXmlText $orgName)") }
|
||||
if ($userName) { [void]$content.AppendLine("Generated by: $(Get-AtlassianXmlText "$userName$mail")") }
|
||||
[void]$content.AppendLine("Generated: $((Get-Date).ToShortDateString()) $((Get-Date).ToLongTimeString())")
|
||||
}
|
||||
|
||||
if ($script:atlSectionAnchors.Count -gt 0) {
|
||||
[void]$content.AppendLine("<h2>Table of Contents</h2>")
|
||||
Add-AtlassianTableOfContents $content
|
||||
}
|
||||
|
||||
$text = $content.ToString()
|
||||
if ($script:atlOutputType -eq "Full" -and $script:atlBody) {
|
||||
$text += $script:atlBody.ToString()
|
||||
}
|
||||
|
||||
Save-DocumentationFile $text $script:atlOutFile -OpenFile:((Get-DocumentationOutputOption atlassian "AtlassianOpenFile" $true) -eq $true)
|
||||
}
|
||||
|
||||
function Invoke-AtlassianNewObjectGroup {
|
||||
param($groupId)
|
||||
$script:atlObjectHeaderLevel = 2
|
||||
Add-AtlassianHeader (Get-DocObjectTypeString $groupId)
|
||||
}
|
||||
|
||||
function Invoke-AtlassianNewObjectType {
|
||||
param($objectTypeName)
|
||||
$script:atlObjectHeaderLevel = 3
|
||||
Add-AtlassianHeader $objectTypeName
|
||||
$script:atlObjectHeaderLevel = 4
|
||||
}
|
||||
|
||||
function Invoke-AtlassianProcessAllObjects {
|
||||
param($documentationInfo)
|
||||
# ScopeTags consolidated table is deferred (matches HTML provider stub).
|
||||
}
|
||||
|
||||
function Invoke-AtlassianProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
# A documented object may ask to be titled by something other than its display
|
||||
# name (see Get-DocumentationDisplayName). Headings and captions follow it; the
|
||||
# file name below deliberately does not.
|
||||
$objName = Get-DocumentationDisplayName $PolicyObject $documentedObj
|
||||
$script:docDisplayName = $objName
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
if ($script:atlOutputType -eq "Object") {
|
||||
# Table numbering restarts per file (each object is its own page), section
|
||||
# numbering does not - see the header comment on anchor uniqueness.
|
||||
$script:atlTotAnchors = @()
|
||||
$script:atlTotCount = 0
|
||||
$script:atlBody = [System.Text.StringBuilder]::new()
|
||||
$script:atlCurrentItemFileName = Get-AtlassianObjectFileName $PolicyObject
|
||||
}
|
||||
|
||||
Add-AtlassianHeader $objName
|
||||
|
||||
try {
|
||||
foreach ($tableType in @("BasicInfo","FilteredSettings")) {
|
||||
if ($tableType -eq "BasicInfo") {
|
||||
$properties = @("Name","Value")
|
||||
$lngId = "SettingDetails.basics"
|
||||
}
|
||||
else {
|
||||
$properties = if ($documentedObj.DefaultDocumentationProperties) {
|
||||
$documentedObj.DefaultDocumentationProperties
|
||||
} else {
|
||||
@("Name","Value")
|
||||
}
|
||||
$lngId = "TableHeaders.settings"
|
||||
}
|
||||
|
||||
if (($documentedObj.$tableType | Measure-Object).Count -gt 0) {
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $documentedObj.$tableType $properties $lngId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $documentedObj.ComplianceActions @("Action","Schedule","MessageTemplate","EmailCC") "Category.complianceActionsLabel"
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $documentedObj.ApplicabilityRules @("Rule","Property","Value") "SettingDetails.applicabilityRules"
|
||||
}
|
||||
|
||||
Add-AtlassianObjectScripts $documentedObj
|
||||
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Sort-Object -Property Order)) {
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
if ($documentedObj.Assignments[0].RawIntent) {
|
||||
$properties = @("GroupMode","Group","Filter","FilterMode")
|
||||
$settingsObj = $documentedObj.Assignments | Where-Object { $null -ne $_.Settings } | Select-Object -First 1
|
||||
if ($settingsObj) {
|
||||
foreach ($objProp in $settingsObj.Settings.Keys) {
|
||||
if ($objProp -in $properties) { continue }
|
||||
if ($objProp -in @("Category","RawIntent")) { continue }
|
||||
$properties += "Settings.$objProp"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$hasFilter = $false
|
||||
foreach ($a in $documentedObj.Assignments) {
|
||||
if ($a.PSObject.Properties.Name -contains "FilterMode") { $hasFilter = $true; break }
|
||||
}
|
||||
$properties = @("Group")
|
||||
if ($hasFilter) { $properties += @("Filter","FilterMode") }
|
||||
}
|
||||
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $documentedObj.Assignments $properties "TableHeaders.assignments" -AddCategories
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
|
||||
if ($script:atlOutputType -eq "Object") {
|
||||
$fileName = Join-Path $script:atlDocumentPath $script:atlCurrentItemFileName
|
||||
Save-DocumentationFile $script:atlBody.ToString() $fileName
|
||||
$script:atlBody = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AtlassianObjectFileName {
|
||||
param($PolicyObject)
|
||||
|
||||
$objName = if ($PolicyObject.Name) { [string]$PolicyObject.Name } else { 'Unnamed policy' }
|
||||
$id = if ($PolicyObject.Id) { [string]$PolicyObject.Id } else { $null }
|
||||
$typeId = if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.Id) { [string]$PolicyObject.PolicyType.Id } else { $null }
|
||||
$suffix = if ($typeId -and $id) { " [$typeId-$id]" }
|
||||
elseif ($id) { " [$id]" }
|
||||
else { '' }
|
||||
return Remove-InvalidFileNameChars "$objName$suffix.html"
|
||||
}
|
||||
|
||||
# Escape text for storage format. Confluence storage format is strict XML and
|
||||
# declares only the five XML built-in entities, so a bare '&' or '<' arriving
|
||||
# from tenant data (policy names, localized captions) makes the whole document
|
||||
# body malformed and Confluence rejects the upload - not just that heading.
|
||||
# Values inside table cells go through Set-AtlassianText, which does this plus
|
||||
# the code/expand macro wrapping; headers and TOC labels need only the escape.
|
||||
function Get-AtlassianXmlText {
|
||||
param([string]$Text)
|
||||
|
||||
if (-not $Text) { return "" }
|
||||
return $Text.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
|
||||
# The author-controlled link target for a heading.
|
||||
#
|
||||
# Confluence strips author-specified id= attributes when it converts storage
|
||||
# format to ADF, so an id alone is not linkable - '#name' only ever resolves to
|
||||
# an anchor macro or to Confluence's own heading-text-derived anchor.
|
||||
#
|
||||
# The macro stays inline inside the heading until the downstream rewrite observed
|
||||
# in Docs/AtlassianAnchorVerification-2026-09-15.md has been attributed. Moving it
|
||||
# to a preceding paragraph before that measurement was an unverified fix that could
|
||||
# add a blank line at every heading without changing the publisher's output. Inline
|
||||
# placement is legal ADF (`anchor` is an inline macro and headings accept inline
|
||||
# content), keeps the jump target on the heading, and is the known baseline while
|
||||
# the runbook and Confluence import paths are tested separately.
|
||||
#
|
||||
# Attributes are single-quoted like every other macro in this file. Consumers
|
||||
# JSON-escape the document body before publishing it, and a double-quoted
|
||||
# attribute arrives as ac:name=\"anchor\" and breaks the macro.
|
||||
function Get-AtlassianAnchorMacro {
|
||||
param([string]$Name)
|
||||
|
||||
if (-not $Name) { return "" }
|
||||
return "<ac:structured-macro ac:name='anchor' ac:schema-version='1'>" +
|
||||
"<ac:parameter ac:name=''>$Name</ac:parameter>" +
|
||||
"</ac:structured-macro>"
|
||||
}
|
||||
|
||||
# Build the href for a TOC entry: a percent-encoded relative file name plus the
|
||||
# anchor fragment, safe to drop into a single-quoted attribute.
|
||||
#
|
||||
# In 'Object' mode the file name comes from the policy name (see
|
||||
# Get-AtlassianObjectFileName), and only path-invalid characters are stripped
|
||||
# from it. '&', '<' and "'" therefore survive - one of them makes the whole
|
||||
# document body malformed XML, or terminates the attribute - and a '#' in a
|
||||
# policy name would open a second fragment and retarget the link. Percent-encode
|
||||
# the file-name component (never the '#' that separates the fragment), then
|
||||
# XML-escape what is left.
|
||||
function Get-AtlassianHref {
|
||||
param([string]$FileName, [string]$Anchor)
|
||||
|
||||
$target = ""
|
||||
if ($FileName) {
|
||||
# A bare file name, no directory separators, so encoding the whole string
|
||||
# is correct. EscapeDataString covers ' on .NET Core but not on every
|
||||
# .NET Framework version; the explicit replace is a no-op when it did.
|
||||
$target = [Uri]::EscapeDataString($FileName).Replace("'", "%27")
|
||||
}
|
||||
|
||||
return Get-AtlassianXmlText "$target#$Anchor"
|
||||
}
|
||||
|
||||
function Add-AtlassianHeader {
|
||||
param(
|
||||
[string]$HeaderText,
|
||||
[int]$Level = $script:atlObjectHeaderLevel,
|
||||
[switch]$ToT,
|
||||
[switch]$SkipTOC
|
||||
)
|
||||
|
||||
if ($ToT) {
|
||||
# 'Table N. ' is a visible caption prefix - that is what the Markdown
|
||||
# provider does with it. It used to be prepended to the id instead of to
|
||||
# the text, producing id="Table 1. table-1": spaces and a period in an
|
||||
# identifier, and no visible numbering anywhere. The number matches the
|
||||
# 'table-N' anchor below.
|
||||
$HeaderText = "Table $($script:atlTotCount + 1). $HeaderText"
|
||||
}
|
||||
|
||||
if ($script:atlBody) {
|
||||
# Every heading that reaches the body consumes a number, whether or not it
|
||||
# is listed in the TOC. Numbering off $atlSectionAnchors.Count instead gave
|
||||
# a -SkipTOC heading (script captions, Add-AtlassianObjectScripts) the same
|
||||
# 'section-N' as the next listed heading: two anchor macros with one name,
|
||||
# so the TOC entry for the policy jumped to the script caption above it.
|
||||
if ($ToT) {
|
||||
$script:atlTotCount++
|
||||
$sectionAnchor = "table-$($script:atlTotCount)"
|
||||
}
|
||||
else {
|
||||
$script:atlSectionCount++
|
||||
$sectionAnchor = "section-$($script:atlSectionCount)"
|
||||
}
|
||||
|
||||
# id= is kept even though Confluence discards it: consumers parse the
|
||||
# generated file (before upload) and read the anchor from it, so it must
|
||||
# stay byte-identical to the anchor macro name.
|
||||
$anchorMacro = Get-AtlassianAnchorMacro $sectionAnchor
|
||||
[void]$script:atlBody.AppendLine("<h$Level id='$sectionAnchor'>$anchorMacro$(Get-AtlassianXmlText $HeaderText)</h$Level>")
|
||||
$fileName = $script:atlCurrentItemFileName
|
||||
}
|
||||
else {
|
||||
$sectionAnchor = $null
|
||||
$fileName = $null
|
||||
}
|
||||
|
||||
if ($ToT) {
|
||||
$script:atlTotAnchors += [PSCustomObject]@{
|
||||
Name = $HeaderText; Anchor = $sectionAnchor; Level = $Level; FileName = $fileName
|
||||
}
|
||||
}
|
||||
elseif (-not $SkipTOC) {
|
||||
$script:atlSectionAnchors += [PSCustomObject]@{
|
||||
Name = $HeaderText; Anchor = $sectionAnchor; Level = $Level; FileName = $fileName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Render the per-run table of contents into $Content.
|
||||
#
|
||||
# Each entry links the anchor its heading actually emitted. Deriving the target
|
||||
# from the heading text instead - which this did - is unreliable twice over:
|
||||
# duplicate policy names all resolved to the first occurrence (Confluence
|
||||
# disambiguates its own text-derived anchors with .1/.2, which a naive
|
||||
# derivation cannot reproduce), and every character other than a plain space
|
||||
# survived into the fragment, including '.', '(', ')', ':' and U+00A0.
|
||||
#
|
||||
# Confluence still generates its text-derived anchors, so externally saved
|
||||
# '#Policy-Name' links keep working; only the TOC moves to the reliable form.
|
||||
function Add-AtlassianTableOfContents {
|
||||
param(
|
||||
[System.Text.StringBuilder]$Content,
|
||||
[int]$MaxLevel = 4
|
||||
)
|
||||
|
||||
foreach ($header in $script:atlSectionAnchors) {
|
||||
if ($MaxLevel -gt 0 -and $header.Level -gt $MaxLevel) { continue }
|
||||
# Nest visually via non-breaking-space padding - Confluence doesn't honour
|
||||
# CSS anchor-level classes on imported storage-format content. Use the
|
||||
# numeric reference   (not the HTML entity ): storage format is
|
||||
# strict XML and only declares the five XML built-in entities.
|
||||
$indent = ""
|
||||
for ($i = 2; $i -lt $header.Level; $i++) { $indent += "  " }
|
||||
|
||||
$label = Get-AtlassianXmlText $header.Name
|
||||
if ($header.Anchor) {
|
||||
$href = Get-AtlassianHref $header.FileName $header.Anchor
|
||||
[void]$Content.AppendLine("$indent<a href='$href'>$label</a>")
|
||||
}
|
||||
else {
|
||||
# Registered while no body was open (a group/type header in 'Object'
|
||||
# mode), so the heading exists in no file and has no anchor. A
|
||||
# '#'-only href would jump to the top of the page instead; emit the
|
||||
# label as plain text.
|
||||
[void]$Content.AppendLine("$indent$label")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Add-AtlassianTableItems {
|
||||
param(
|
||||
$PolicyObject,
|
||||
[string]$TypeTitle,
|
||||
$Items,
|
||||
[string[]]$Properties,
|
||||
[string]$LngId,
|
||||
[switch]$AddCategories,
|
||||
[switch]$AddSubcategories,
|
||||
$CaptionOverride
|
||||
)
|
||||
|
||||
if ($CaptionOverride) {
|
||||
$caption = $CaptionOverride
|
||||
}
|
||||
elseif ($LngId -and $PolicyObject) {
|
||||
$caption = "$((Get-LanguageString $LngId)) - $(Get-DocCaptionName $PolicyObject)"
|
||||
}
|
||||
elseif ($PolicyObject) {
|
||||
$caption = "$(Get-DocCaptionName $PolicyObject) ($TypeTitle)"
|
||||
}
|
||||
else {
|
||||
$caption = $TypeTitle
|
||||
}
|
||||
|
||||
$table = [System.Text.StringBuilder]::new()
|
||||
[void]$table.AppendLine("<table>")
|
||||
[void]$table.AppendLine("<tr>")
|
||||
|
||||
$columnCount = 0
|
||||
foreach ($prop in $Properties) {
|
||||
[void]$table.AppendLine("<th>$((Invoke-DocTranslateColumnHeader $prop.Split('.')[-1]))</th>")
|
||||
$columnCount++
|
||||
}
|
||||
[void]$table.AppendLine("</tr>")
|
||||
|
||||
$curCategory = ""
|
||||
$curSubCategory = ""
|
||||
|
||||
foreach ($itemObj in $Items) {
|
||||
if ($itemObj.Category -and $curCategory -ne $itemObj.Category -and $AddCategories) {
|
||||
[void]$table.AppendLine("<tr><td colspan='$columnCount'><strong>$($itemObj.Category)</strong></td></tr>")
|
||||
$curCategory = $itemObj.Category
|
||||
$curSubCategory = ""
|
||||
}
|
||||
|
||||
if ($itemObj.SubCategory -and $curSubCategory -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
[void]$table.AppendLine("<tr><td colspan='$columnCount'><em>$($itemObj.SubCategory)</em></td></tr>")
|
||||
$curSubCategory = $itemObj.SubCategory
|
||||
}
|
||||
|
||||
try {
|
||||
[void]$table.AppendLine("<tr>")
|
||||
|
||||
$curCol = 0
|
||||
foreach ($prop in $Properties) {
|
||||
$curCol++
|
||||
try {
|
||||
$propArr = $prop.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
|
||||
if ($propName -eq "Value" -and ($itemObj.FullValueTable | Measure-Object).Count -gt 0) {
|
||||
[void]$table.AppendLine("<td><table><tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$table.AppendLine("<th>$($colProp.Name)</th>")
|
||||
}
|
||||
[void]$table.AppendLine("</tr>")
|
||||
foreach ($rowVal in $itemObj.FullValueTable) {
|
||||
[void]$table.AppendLine("<tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$table.AppendLine("<td>$((Set-AtlassianText $rowVal."$($colProp.Name)"))</td>")
|
||||
}
|
||||
[void]$table.AppendLine("</tr>")
|
||||
}
|
||||
[void]$table.AppendLine("</table></td>")
|
||||
}
|
||||
else {
|
||||
$indent = ""
|
||||
if ($curCol -eq 1 -and $itemObj.Level) {
|
||||
try {
|
||||
# One indent unit per nesting level (Level 1 = first
|
||||
# indent), matching the HTML/MD/Word providers. Was
|
||||
# off-by-one ($i started at 1), so Level-1 children
|
||||
# rendered flush. Negative levels produce no indent.
|
||||
$level = [int]$itemObj.Level
|
||||
#   (numeric non-breaking space) not —
|
||||
# Confluence storage format is strict XML and only
|
||||
# declares the five XML built-in entities, so
|
||||
# is dropped/rejected. Numeric refs always render.
|
||||
for ($i = 0; $i -lt $level; $i++) { $indent += "  " }
|
||||
} catch {}
|
||||
}
|
||||
[void]$table.AppendLine("<td>$($indent)$((Set-AtlassianText $tmpObj.$propName))</td>")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add property value for $prop" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to process property" 2
|
||||
}
|
||||
finally {
|
||||
[void]$table.AppendLine("</tr>")
|
||||
}
|
||||
}
|
||||
|
||||
[void]$table.AppendLine("</table>")
|
||||
[void]$script:atlBody.Append($table.ToString())
|
||||
# No -ToT: the caption is a plain level-6 heading, matching the HTML provider.
|
||||
# Passing -ToT here would add visible 'Table N. ' numbering (what the Markdown
|
||||
# provider does) and move the caption into $atlTotAnchors. That is a formatting
|
||||
# decision for the HTML and Atlassian outputs together, not a port detail.
|
||||
Add-AtlassianHeader $caption -Level 6
|
||||
}
|
||||
|
||||
# Confluence Storage Format text emitter. Escapes HTML special chars in plain
|
||||
# text; wraps XML-looking values in a `code` macro; wraps long text (>250
|
||||
# chars) in an `expand` macro with a first-line summary as the caption.
|
||||
function Set-AtlassianText {
|
||||
param([string]$Text, [switch]$NoCodeBlock)
|
||||
|
||||
if (-not $Text) { return }
|
||||
|
||||
$txtSummary = ""
|
||||
if ($Text.Length -gt 250) {
|
||||
$summaryMax = 40
|
||||
$idx = $Text.IndexOfAny(@("`r","`n"))
|
||||
if ($idx -gt 10 -and $idx -lt 50) { $summaryMax = $idx }
|
||||
$txtSummary = $Text.Substring(0, $summaryMax)
|
||||
}
|
||||
|
||||
$isCode = $false
|
||||
if (-not $NoCodeBlock) {
|
||||
$trim = $Text.Trim()
|
||||
if ($trim.StartsWith("<") -and $trim.EndsWith(">")) {
|
||||
$isCode = $true
|
||||
$Text = "<ac:structured-macro ac:name='code' ac:schema-version='1'>" +
|
||||
"<ac:parameter ac:name='language'>xml</ac:parameter>" +
|
||||
"<ac:plain-text-body><![CDATA[$Text]]></ac:plain-text-body>" +
|
||||
"</ac:structured-macro>"
|
||||
if ($txtSummary) {
|
||||
$txtSummary = $txtSummary.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $isCode) {
|
||||
$Text = $Text.Replace('&','&').Replace('<','<').Replace('>','>') #.Replace("`r`n",'<br />').Replace("`n",'<br />')
|
||||
}
|
||||
|
||||
if ($txtSummary) {
|
||||
"<ac:structured-macro ac:name='expand' ac:schema-version='1'>" +
|
||||
"<ac:parameter ac:name='title'>$txtSummary...</ac:parameter>" +
|
||||
"<ac:rich-text-body>$Text</ac:rich-text-body>" +
|
||||
"</ac:structured-macro>"
|
||||
}
|
||||
else {
|
||||
$Text
|
||||
}
|
||||
}
|
||||
|
||||
function Add-AtlassianObjectScripts {
|
||||
param($documentedObj)
|
||||
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent -or -not $scriptItem.Caption) { continue }
|
||||
[void]$script:atlBody.AppendLine("<ac:structured-macro ac:name='code' ac:schema-version='1'>")
|
||||
[void]$script:atlBody.AppendLine("<ac:parameter ac:name='language'>powershell</ac:parameter>")
|
||||
[void]$script:atlBody.AppendLine("<ac:plain-text-body><![CDATA[")
|
||||
[void]$script:atlBody.AppendLine($scriptItem.ScriptContent)
|
||||
[void]$script:atlBody.AppendLine("]]></ac:plain-text-body>")
|
||||
[void]$script:atlBody.AppendLine("</ac:structured-macro>")
|
||||
Add-AtlassianHeader $scriptItem.Caption -Level 6 -SkipTOC
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeAtlassianOutput
|
||||
@@ -0,0 +1,129 @@
|
||||
# CSV output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result (see DocumentationOutputJson.ps1
|
||||
# header for the field list). Writes one CSV file per object under
|
||||
# <RootFolder>[/<Organization>][/<ObjectType.Id>]/<ObjectName>.csv.
|
||||
#
|
||||
# Headless: explicit public options override persisted Documentation settings.
|
||||
# UI form construction belongs to the active UI backend; this file no longer
|
||||
# imports XAML at module load. See [[architecture-rules]] R1/R2.
|
||||
#
|
||||
# Settings consumed:
|
||||
# CSVExportProperties simple | extended | custom default 'simple'
|
||||
# CSVCustomDisplayProperties comma-separated property names default 'Name,Value,Category'
|
||||
# CSVDelimiter CSV delimiter character default '' (auto)
|
||||
# CSVDocumentationPath root folder for export default ''
|
||||
# CSVAddObjectType $true to nest under ObjectType default $true
|
||||
# CSVAddCompanyName $true to nest under Organization default $false
|
||||
|
||||
function Invoke-InitializeCSVOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "CSV"
|
||||
Value = "csv"
|
||||
# UI hints — where does this provider store its primary output path, and
|
||||
# is that path a folder (CSV writes many files) or a file?
|
||||
PrimaryPathOption = "CSVDocumentationPath"
|
||||
PathIsFolder = $true
|
||||
PreProcess = { Invoke-CSVPreProcessItems @args }
|
||||
Process = { Invoke-CSVProcessItem @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-CSVPreProcessItems {
|
||||
# No-op. Settings are the source of truth; the UI form (when wired) writes
|
||||
# them via Save-SettingStoreValue directly. Hook retained for symmetry / future use.
|
||||
}
|
||||
|
||||
function Invoke-CSVProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
$rootFolder = Get-DocumentationOutputOption csv "CSVDocumentationPath" ""
|
||||
$addObjectType = (Get-DocumentationOutputOption csv "CSVAddObjectType" $true) -eq $true
|
||||
$addCompanyName = (Get-DocumentationOutputOption csv "CSVAddCompanyName" $false) -eq $true
|
||||
$folder = Get-DocObjectFolder -RootFolder $rootFolder -PolicyType $PolicyObject.PolicyType -AddObjectType:$addObjectType -AddOrganization:$addCompanyName
|
||||
|
||||
$objName = $PolicyObject.Name
|
||||
|
||||
try {
|
||||
if (-not [IO.Directory]::Exists($folder)) {
|
||||
[IO.Directory]::CreateDirectory($folder) | Out-Null
|
||||
}
|
||||
|
||||
$mode = Get-DocumentationOutputOption csv "CSVExportProperties" "simple"
|
||||
$customProps = Get-DocumentationOutputOption csv "CSVCustomDisplayProperties" "Name,Value,Category"
|
||||
$delimiter = Get-DocumentationOutputOption csv "CSVDelimiter" ""
|
||||
|
||||
$csvParams = @{}
|
||||
if ($delimiter) { $csvParams['Delimiter'] = $delimiter }
|
||||
|
||||
$itemsToExport = @()
|
||||
|
||||
$useSectioned = ($mode -eq 'extended' -and $documentedObj.DisplayProperties) -or
|
||||
($mode -eq 'custom' -and $customProps)
|
||||
|
||||
if ($useSectioned) {
|
||||
if (($documentedObj.BasicInfo | Measure-Object).Count -gt 0) {
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Basic info"
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += $documentedObj.BasicInfo | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
if (($documentedObj.FilteredSettings | Measure-Object).Count -gt 0) {
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Settings"
|
||||
$itemsToExport += ""
|
||||
if ($mode -eq 'extended') {
|
||||
$displayProperties = $documentedObj.DisplayProperties
|
||||
}
|
||||
else {
|
||||
$displayProperties = $customProps.Split(",")
|
||||
}
|
||||
$itemsToExport += $documentedObj.FilteredSettings | Select-Object $displayProperties | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Applicability Rules"
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += $documentedObj.ApplicabilityRules | Select-Object Rule, Property, Value, Category | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Compliance Actions"
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += $documentedObj.ComplianceActions | Select-Object Action, Schedule, MessageTemplate, EmailCC, Category | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
if ($documentedObj.Assignments[0].RawIntent) { $properties = @("GroupMode","Group","Category","SubCategory") }
|
||||
elseif ($documentedObj.Assignments[0].Group) { $properties = @("GroupMode","Group","Category") }
|
||||
else { $properties = @("GroupMode","Groups","Category") }
|
||||
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Assignments"
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += $documentedObj.Assignments | Select-Object $properties | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
}
|
||||
else {
|
||||
$rows = @()
|
||||
$rows += $documentedObj.BasicInfo
|
||||
$rows += $documentedObj.FilteredSettings
|
||||
$itemsToExport = $rows | Select-Object Name, Value | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
$safeName = Remove-InvalidFileNameChars $objName
|
||||
$fileName = Join-Path $folder "$safeName.csv"
|
||||
Write-Log "Save documentation to $fileName"
|
||||
$itemsToExport | Out-File -LiteralPath $fileName -Encoding utf8 -Force
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to save CSV file for $objName in $folder" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeCSVOutput
|
||||
@@ -0,0 +1,476 @@
|
||||
# HTML output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result (see DocumentationOutputJson.ps1
|
||||
# header for the field list). Renders a single HTML document (or one file per
|
||||
# object) with CSS from Internal/Documentation/Assets/DefaultHTMLStyle.css.
|
||||
#
|
||||
# Differences vs old DocumentationHTML.psm1:
|
||||
# - Drops V1 NewObjectGroup/NewObjectType hooks (V2 string-arg is registered)
|
||||
# - Drops extended/custom property selectors that read $global:txt*/$global:cb*
|
||||
# UI controls. Always uses DefaultDocumentationProperties or ('Name','Value').
|
||||
# Phase 2 [DocumentationContext] will reintroduce these via $ctx.Options.
|
||||
# - Uses the typed-object API: $PolicyObject.Name + $PolicyObject.PolicyType.Title
|
||||
# - Invoke-HTMLProcessAllObjects (ScopeTags consolidated table) is stubbed
|
||||
# because Get-TableObjects doesn't exist yet — wire up in phase 2.
|
||||
|
||||
function Invoke-InitializeHTMLOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "HTML"
|
||||
Value = "html"
|
||||
# Path metadata for the bulk-doc UI: which option key stores the
|
||||
# primary output path, and whether that path is a folder or a file.
|
||||
# File-picker button wiring for the UI is declared separately in
|
||||
# UI/<backend>/ClassExtensions/DocumentationOutputHTMLUIExtension.ps1
|
||||
# to keep XAML control names + toolkit-specific filter strings out
|
||||
# of this pure-logic file.
|
||||
PrimaryPathOption = "HTMLDocumentName"
|
||||
PathIsFolder = $false
|
||||
PreProcess = { Invoke-HTMLPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-HTMLNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-HTMLNewObjectType @args }
|
||||
Process = { Invoke-HTMLProcessItem @args }
|
||||
PostProcess = { Invoke-HTMLPostProcessItems @args }
|
||||
ProcessAllObjects = { Invoke-HTMLProcessAllObjects @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-HTMLPreProcessItems {
|
||||
$script:sectionAnchors = @()
|
||||
$script:totAnchors = @()
|
||||
$script:htmlStrings = $null
|
||||
$script:currentItemFileName = $null
|
||||
|
||||
$defaultCSSFile = [IO.Path]::Combine($script:AppRootFolder, "Internal", "Documentation", "Assets", "DefaultHTMLStyle.css")
|
||||
$htmlCssFile = Get-DocumentationOutputOption html "HTMLCSSFile" $defaultCSSFile
|
||||
|
||||
if (-not $htmlCssFile) {
|
||||
Write-Log "CSS file not specified. Using default" 2
|
||||
$htmlCssFile = $defaultCSSFile
|
||||
}
|
||||
elseif (-not [IO.File]::Exists($htmlCssFile)) {
|
||||
Write-Log "CSS file $htmlCssFile not found. Using default" 2
|
||||
$htmlCssFile = $defaultCSSFile
|
||||
}
|
||||
|
||||
if ([IO.File]::Exists($htmlCssFile)) {
|
||||
Write-Log "Using CSS file $htmlCssFile"
|
||||
$script:cssStyle = ([IO.File]::ReadAllText($htmlCssFile)) + [Environment]::NewLine
|
||||
}
|
||||
else {
|
||||
Write-Log "CSS file $htmlCssFile not found. No styles applied" 2
|
||||
$script:cssStyle = ""
|
||||
}
|
||||
|
||||
$fileName = Get-DocumentationOutputOption html "HTMLDocumentName" ""
|
||||
if (-not $fileName) { $fileName = "%MyDocuments%\%Organization%-%Date%.html" }
|
||||
$fileName = Expand-FileName $fileName
|
||||
|
||||
$script:outFile = $fileName
|
||||
$script:documentPath = [IO.Path]::GetDirectoryName($fileName)
|
||||
$script:outputType = Get-DocumentationOutputOption html "HTMLDocumentFileType" "Full"
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
Write-Log "Document one file for each object + index file"
|
||||
}
|
||||
else {
|
||||
Write-Log "Document one single file for all objects"
|
||||
$script:outputType = "Full"
|
||||
$script:htmlStrings = [System.Text.StringBuilder]::new()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-HTMLPostProcessItems {
|
||||
$userName = $null
|
||||
$mail = ""
|
||||
$me = Get-CurrentUser
|
||||
if ($me) {
|
||||
if ($me.givenName -and $me.surname) {
|
||||
$userName = "$($me.givenName) $($me.surname)"
|
||||
}
|
||||
else {
|
||||
$userName = $me.displayName
|
||||
}
|
||||
if ($me.mail) { $mail = " ($($me.mail))" }
|
||||
}
|
||||
|
||||
$orgName = Get-CurrentOrganizationName
|
||||
|
||||
$title = Get-DocumentationOutputOption html "HTMLTitleProperty" "Intune documentation"
|
||||
if (-not $title) { $title = "Intune documentation" }
|
||||
|
||||
$htmlContent = [System.Text.StringBuilder]::new()
|
||||
[void]$htmlContent.AppendLine("<HTML>")
|
||||
[void]$htmlContent.AppendLine($script:cssStyle)
|
||||
[void]$htmlContent.AppendLine("<H1 class='header-level1'>$title</H1>")
|
||||
|
||||
if (-not ((Get-DocumentationOption "SkipDocumentInfo" $false) -eq $true)) {
|
||||
if ($orgName) { [void]$htmlContent.AppendLine("Organization: $orgName<br />") }
|
||||
if ($userName) { [void]$htmlContent.AppendLine("Generated by: $userName$mail<br />") }
|
||||
[void]$htmlContent.AppendLine("Generated: $((Get-Date).ToShortDateString()) $((Get-Date).ToLongTimeString())<br />")
|
||||
}
|
||||
|
||||
if ($script:sectionAnchors.Count -gt 0) {
|
||||
[void]$htmlContent.AppendLine("<br />")
|
||||
[void]$htmlContent.AppendLine("<H2 class='header-level2'>Table of Contents</H2>")
|
||||
}
|
||||
|
||||
$tocMaxLevel = 4
|
||||
foreach ($header in $script:sectionAnchors) {
|
||||
if ($tocMaxLevel -gt 0 -and $header.Level -gt $tocMaxLevel) { continue }
|
||||
[void]$htmlContent.AppendLine("<a href='$($header.FileName)#$($header.Anchor)' class='anchor-style anchor-level$($header.Level)'>$($header.Name)</a><br />")
|
||||
}
|
||||
if ($script:sectionAnchors.Count -gt 0) { [void]$htmlContent.AppendLine("<br />") }
|
||||
|
||||
$htmlText = $htmlContent.ToString()
|
||||
if ($script:outputType -eq "Full" -and $script:htmlStrings) {
|
||||
$htmlText += $script:htmlStrings.ToString()
|
||||
}
|
||||
$htmlText += "</HTML>"
|
||||
|
||||
Save-DocumentationFile $htmlText $script:outFile -OpenFile:((Get-DocumentationOutputOption html "HTMLOpenFile" $true) -eq $true)
|
||||
}
|
||||
|
||||
function Invoke-HTMLNewObjectGroup {
|
||||
param($groupId)
|
||||
$script:objectHeaderLevel = 2
|
||||
Add-HTMLHeader (Get-DocObjectTypeString $groupId)
|
||||
}
|
||||
|
||||
function Invoke-HTMLNewObjectType {
|
||||
param($objectTypeName)
|
||||
$script:objectHeaderLevel = 3
|
||||
Add-HTMLHeader $objectTypeName
|
||||
$script:objectHeaderLevel = 4
|
||||
}
|
||||
|
||||
function Invoke-HTMLProcessAllObjects {
|
||||
param($documentationInfo)
|
||||
# ScopeTags consolidated table is deferred — Get-TableObjects helper lands
|
||||
# in phase 2 alongside the engine. For now this is a no-op.
|
||||
}
|
||||
|
||||
function Invoke-HTMLProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
# A documented object may ask to be titled by something other than its display
|
||||
# name (see Get-DocumentationDisplayName). Headings and captions follow it; the
|
||||
# file name below deliberately does not.
|
||||
$objName = Get-DocumentationDisplayName $PolicyObject $documentedObj
|
||||
$script:docDisplayName = $objName
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
$script:totAnchors = @()
|
||||
$script:htmlStrings = [System.Text.StringBuilder]::new()
|
||||
$script:currentItemFileName = Get-HTMLObjectFileName $PolicyObject
|
||||
}
|
||||
|
||||
Add-HTMLHeader $objName
|
||||
[void]$script:htmlStrings.AppendLine("<br />")
|
||||
|
||||
try {
|
||||
foreach ($tableType in @("BasicInfo","FilteredSettings")) {
|
||||
if ($tableType -eq "BasicInfo") {
|
||||
$properties = @("Name","Value")
|
||||
$lngId = "SettingDetails.basics"
|
||||
}
|
||||
else {
|
||||
if ($documentedObj.DefaultDocumentationProperties) {
|
||||
$properties = $documentedObj.DefaultDocumentationProperties
|
||||
}
|
||||
else {
|
||||
$properties = @("Name","Value")
|
||||
}
|
||||
$lngId = "TableHeaders.settings"
|
||||
}
|
||||
|
||||
# Custom tables with a negative Order belong ABOVE the settings
|
||||
# table: the portal shows a MAM app config's "Settings catalog"
|
||||
# blade above its "Settings" blade.
|
||||
if ($tableType -eq "FilteredSettings") {
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -lt 0 } | Sort-Object -Property Order)) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.$tableType | Measure-Object).Count -gt 0) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $documentedObj.$tableType $properties $lngId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $documentedObj.ComplianceActions @("Action","Schedule","MessageTemplate","EmailCC") "Category.complianceActionsLabel"
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $documentedObj.ApplicabilityRules @("Rule","Property","Value") "SettingDetails.applicabilityRules"
|
||||
}
|
||||
|
||||
Add-HTMLObjectScripts $documentedObj
|
||||
|
||||
# Negative Order already rendered above the settings table.
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -ge 0 } | Sort-Object -Property Order)) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
if ($documentedObj.Assignments[0].RawIntent) {
|
||||
$properties = @("GroupMode","Group","Filter","FilterMode")
|
||||
$settingsObj = $documentedObj.Assignments | Where-Object { $_.Settings -ne $null } | Select-Object -First 1
|
||||
if ($settingsObj) {
|
||||
foreach ($objProp in $settingsObj.Settings.Keys) {
|
||||
if ($objProp -in $properties) { continue }
|
||||
if ($objProp -in @("Category","RawIntent")) { continue }
|
||||
$properties += "Settings.$objProp"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$hasFilter = $false
|
||||
foreach ($a in $documentedObj.Assignments) {
|
||||
if ($a.PSObject.Properties.Name -contains "FilterMode") { $hasFilter = $true; break }
|
||||
}
|
||||
$properties = @("Group")
|
||||
if ($hasFilter) { $properties += @("Filter","FilterMode") }
|
||||
}
|
||||
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $documentedObj.Assignments $properties "TableHeaders.assignments" -AddCategories
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
$perObject = [System.Text.StringBuilder]::new()
|
||||
[void]$perObject.AppendLine("<HTML>")
|
||||
[void]$perObject.AppendLine($script:cssStyle)
|
||||
$htmlText = $perObject.ToString() + $script:htmlStrings.ToString() + "</HTML>"
|
||||
$fileName = Join-Path $script:documentPath $script:currentItemFileName
|
||||
Save-DocumentationFile $htmlText $fileName
|
||||
$script:htmlStrings = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-HTMLObjectFileName {
|
||||
param($PolicyObject)
|
||||
|
||||
$objName = if ($PolicyObject.Name) { [string]$PolicyObject.Name } else { 'Unnamed policy' }
|
||||
$id = if ($PolicyObject.Id) { [string]$PolicyObject.Id } else { $null }
|
||||
$typeId = if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.Id) { [string]$PolicyObject.PolicyType.Id } else { $null }
|
||||
$suffix = if ($typeId -and $id) { " [$typeId-$id]" }
|
||||
elseif ($id) { " [$id]" }
|
||||
else { '' }
|
||||
return Remove-InvalidFileNameChars "$objName$suffix.html"
|
||||
}
|
||||
|
||||
function Add-HTMLHeader {
|
||||
param(
|
||||
[string]$HeaderText,
|
||||
[int]$Level = $script:objectHeaderLevel,
|
||||
[switch]$ToT,
|
||||
[switch]$SkipTOC
|
||||
)
|
||||
|
||||
if ($script:htmlStrings) {
|
||||
$prefix = ""
|
||||
if ($ToT) {
|
||||
$prefix = "Table $($script:totAnchors.Count + 1). "
|
||||
$sectionAnchor = "table-$($script:totAnchors.Count + 1)"
|
||||
}
|
||||
else {
|
||||
$sectionAnchor = "section-$($script:sectionAnchors.Count + 1)"
|
||||
}
|
||||
|
||||
[void]$script:htmlStrings.AppendLine("<H$Level id=`"$prefix$sectionAnchor`" class='header-level$Level'>$HeaderText</H$Level>")
|
||||
$fileName = $script:currentItemFileName
|
||||
}
|
||||
else {
|
||||
$sectionAnchor = $null
|
||||
$fileName = $null
|
||||
}
|
||||
|
||||
if ($ToT) {
|
||||
$script:totAnchors += [PSCustomObject]@{
|
||||
Name = $HeaderText; Anchor = $sectionAnchor; Level = $Level; FileName = $fileName
|
||||
}
|
||||
}
|
||||
elseif (-not $SkipTOC) {
|
||||
$script:sectionAnchors += [PSCustomObject]@{
|
||||
Name = $HeaderText; Anchor = $sectionAnchor; Level = $Level; FileName = $fileName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Add-HTMLTableItems {
|
||||
param(
|
||||
$PolicyObject,
|
||||
[string]$TypeTitle,
|
||||
$Items,
|
||||
[string[]]$Properties,
|
||||
[string]$LngId,
|
||||
[switch]$AddCategories,
|
||||
[switch]$AddSubcategories,
|
||||
$CaptionOverride
|
||||
)
|
||||
|
||||
if ($CaptionOverride) {
|
||||
$caption = $CaptionOverride
|
||||
}
|
||||
elseif ($LngId -and $PolicyObject) {
|
||||
$caption = "$((Get-LanguageString $LngId)) - $(Get-DocCaptionName $PolicyObject)"
|
||||
}
|
||||
elseif ($PolicyObject) {
|
||||
$caption = "$(Get-DocCaptionName $PolicyObject) ($TypeTitle)"
|
||||
}
|
||||
else {
|
||||
$caption = $TypeTitle
|
||||
}
|
||||
|
||||
$tableText = [System.Text.StringBuilder]::new()
|
||||
[void]$tableText.AppendLine("<table class='table-settings'>")
|
||||
[void]$tableText.AppendLine("<tr>")
|
||||
|
||||
$columnCount = 0
|
||||
foreach ($prop in $Properties) {
|
||||
[void]$tableText.AppendLine("<th>$((Invoke-DocTranslateColumnHeader $prop.Split('.')[-1]))</th>")
|
||||
$columnCount++
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
|
||||
$curCategory = ""
|
||||
$curSubCategory = ""
|
||||
$row = 1
|
||||
|
||||
foreach ($itemObj in $Items) {
|
||||
$additionalRowClass = ""
|
||||
|
||||
if ($itemObj.Category -and $curCategory -ne $itemObj.Category -and $AddCategories) {
|
||||
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level1'>$($itemObj.Category)</td></tr>")
|
||||
$curCategory = $itemObj.Category
|
||||
$curSubCategory = ""
|
||||
$row = 1
|
||||
}
|
||||
|
||||
if ($itemObj.SubCategory -and $curSubCategory -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level2'>$($itemObj.SubCategory)</td></tr>")
|
||||
$curSubCategory = $itemObj.SubCategory
|
||||
$row = 1
|
||||
}
|
||||
|
||||
if ($itemObj.PropertyIndex -is [int] -and $itemObj.PropertyIndex -eq 1) {
|
||||
$additionalRowClass = "row-new-property"
|
||||
}
|
||||
|
||||
try {
|
||||
$rowClass = if (($row % 2) -eq 1) { "row-odd" } else { "row-even" }
|
||||
$row++
|
||||
[void]$tableText.AppendLine("<tr class='$rowClass $additionalRowClass'>")
|
||||
|
||||
$curCol = 1
|
||||
foreach ($prop in $Properties) {
|
||||
try {
|
||||
$propArr = $prop.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
|
||||
if ($propName -eq "Value" -and ($itemObj.FullValueTable | Measure-Object).Count -gt 0) {
|
||||
[void]$tableText.AppendLine("<td><table class='table-value'><tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$tableText.AppendLine("<th>$($colProp.Name)</th>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
foreach ($rowVal in $itemObj.FullValueTable) {
|
||||
[void]$tableText.AppendLine("<tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$tableText.AppendLine("<td>$($rowVal."$($colProp.Name)")</td>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</table></td>")
|
||||
}
|
||||
else {
|
||||
$style = ""
|
||||
if ($curCol -eq 1 -and $itemObj.Level) {
|
||||
try {
|
||||
$level = [int]$itemObj.Level
|
||||
$style = " style='padding-left:$((5 + ($level * 5)))px;'"
|
||||
} catch {}
|
||||
}
|
||||
[void]$tableText.AppendLine("<td class='property-column$curCol'$style>$((Set-HTMLText $tmpObj.$propName))</td>")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add property value for $prop" $_.Exception
|
||||
}
|
||||
$curCol++
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to process property" 2
|
||||
}
|
||||
finally {
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
}
|
||||
}
|
||||
|
||||
[void]$tableText.AppendLine("</table>")
|
||||
[void]$script:htmlStrings.Append($tableText.ToString())
|
||||
Add-HTMLHeader $caption -Level 6
|
||||
}
|
||||
|
||||
function Set-HTMLText {
|
||||
param([string]$Text, [switch]$NoCodeBlock)
|
||||
|
||||
if (-not $Text) { return }
|
||||
|
||||
$txtSummary = ""
|
||||
if ($Text.Length -gt 250) {
|
||||
$summaryMax = 40
|
||||
$idx = $Text.IndexOfAny(@("`r","`n"))
|
||||
if ($idx -gt 10 -and $idx -lt 50) { $summaryMax = $idx }
|
||||
$txtSummary = $Text.Substring(0, $summaryMax)
|
||||
}
|
||||
|
||||
$code = $false
|
||||
if (-not $NoCodeBlock) {
|
||||
$trim = $Text.Trim()
|
||||
if ($trim.StartsWith("<") -and $trim.EndsWith(">")) {
|
||||
$code = $true
|
||||
$Text = "<pre class='code'>$($Text.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"'))</pre>"
|
||||
if ($txtSummary) {
|
||||
$txtSummary = $txtSummary.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $code) {
|
||||
$Text = $Text.Replace("`r`n", "<br />").Replace("`n", "<br />").Replace('&', '&')
|
||||
}
|
||||
|
||||
if ($txtSummary) {
|
||||
"<details class='description'><summary data-open='Minimize' data-close='$txtSummary...expand'></summary>$Text</details>"
|
||||
}
|
||||
else {
|
||||
$Text
|
||||
}
|
||||
}
|
||||
|
||||
function Add-HTMLObjectScripts {
|
||||
param($documentedObj)
|
||||
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent -or -not $scriptItem.Caption) { continue }
|
||||
[void]$script:htmlStrings.AppendLine("<pre class='code'>")
|
||||
[void]$script:htmlStrings.AppendLine($scriptItem.ScriptContent)
|
||||
[void]$script:htmlStrings.AppendLine("</pre>")
|
||||
Add-HTMLHeader $scriptItem.Caption -Level 6 -SkipTOC
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeHTMLOutput
|
||||
@@ -0,0 +1,214 @@
|
||||
# JSON output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result:
|
||||
# BasicInfo [{Name, Value}]
|
||||
# FilteredSettings [{Name, Value, Category?, SubCategory?}]
|
||||
# ComplianceActions [{Action, Schedule, MessageTemplate, EmailCC}]
|
||||
# ApplicabilityRules [{Rule, Property, Value}]
|
||||
# CustomTables [{Values[], Columns[], LanguageId?, Order}]
|
||||
# Assignments [{Group, GroupMode?, Filter?, FilterMode?, Settings?, RawIntent?}]
|
||||
# Scripts [{ScriptContent, Caption}] (pre-filtered by engine per options)
|
||||
|
||||
function Invoke-InitializeJsonOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "Json"
|
||||
Value = "json"
|
||||
# Path metadata (see DocumentationOutputHTML.ps1 header comment).
|
||||
# Json exposes no file-browse buttons on the bulk-doc form today;
|
||||
# add UI/<backend>/ClassExtensions/DocumentationOutputJsonUIExtension.ps1
|
||||
# if that changes.
|
||||
PrimaryPathOption = "JSONDocumentName"
|
||||
PathIsFolder = $false
|
||||
PreProcess = { Invoke-JsonPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-JsonNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-JsonNewObjectType @args }
|
||||
Process = { Invoke-JsonProcessItem @args }
|
||||
PostProcess = { Invoke-JsonPostProcessItems @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-JsonPreProcessItems {
|
||||
$script:jsonAllObjects = [System.Collections.Generic.List[object]]::new()
|
||||
$script:jsonCurrentTypeObjects = [System.Collections.Generic.List[object]]::new()
|
||||
$script:jsonCurrentTypeName = $null
|
||||
|
||||
$script:jsonOutputType = Get-DocumentationOutputOption json "JSONOutputFileType" "Full"
|
||||
|
||||
$jsonFileName = Get-DocumentationOutputOption json "JSONDocumentName" ""
|
||||
if (-not $jsonFileName) { $jsonFileName = "%MyDocuments%\%Organization%-%Date%.json" }
|
||||
|
||||
$script:jsonOutFile = Expand-FileName $jsonFileName
|
||||
$script:jsonDocumentPath = [IO.Path]::GetDirectoryName($script:jsonOutFile)
|
||||
}
|
||||
|
||||
function Invoke-JsonNewObjectGroup {
|
||||
param($groupId)
|
||||
# Groups are not used in flat JSON output
|
||||
}
|
||||
|
||||
function Invoke-JsonNewObjectType {
|
||||
param($objectTypeName)
|
||||
|
||||
if ($script:jsonOutputType -eq "ObjectType" -and
|
||||
$script:jsonCurrentTypeName -and
|
||||
$script:jsonCurrentTypeObjects.Count -gt 0) {
|
||||
Save-JsonTypeFile $script:jsonCurrentTypeName $script:jsonCurrentTypeObjects
|
||||
}
|
||||
|
||||
$script:jsonCurrentTypeName = $objectTypeName
|
||||
$script:jsonCurrentTypeObjects = [System.Collections.Generic.List[object]]::new()
|
||||
}
|
||||
|
||||
function Invoke-JsonProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
$objName = $PolicyObject.Name
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
try {
|
||||
$jsonObj = [ordered]@{
|
||||
objectType = $typeTitle
|
||||
name = $objName
|
||||
}
|
||||
|
||||
if (($documentedObj.BasicInfo | Measure-Object).Count -gt 0) {
|
||||
$basicInfo = [ordered]@{}
|
||||
foreach ($item in $documentedObj.BasicInfo) {
|
||||
if ($item.Name) { $basicInfo[$item.Name] = $item.Value }
|
||||
}
|
||||
$jsonObj.basicInfo = $basicInfo
|
||||
}
|
||||
|
||||
if (($documentedObj.FilteredSettings | Measure-Object).Count -gt 0) {
|
||||
$settings = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($item in $documentedObj.FilteredSettings) {
|
||||
$setting = [ordered]@{ name = $item.Name; value = $item.Value }
|
||||
if ($item.Category) { $setting.category = $item.Category }
|
||||
if ($item.SubCategory) { $setting.subCategory = $item.SubCategory }
|
||||
if ($item.PSObject.Properties['Level'] -and $item.Level) { $setting.level = $item.Level }
|
||||
$settings.Add($setting)
|
||||
}
|
||||
$jsonObj.settings = $settings
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
$actions = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($item in $documentedObj.ComplianceActions) {
|
||||
$actions.Add([ordered]@{
|
||||
action = $item.Action
|
||||
schedule = $item.Schedule
|
||||
messageTemplate = $item.MessageTemplate
|
||||
emailCC = $item.EmailCC
|
||||
})
|
||||
}
|
||||
$jsonObj.complianceActions = $actions
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
$rules = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($item in $documentedObj.ApplicabilityRules) {
|
||||
$rules.Add([ordered]@{
|
||||
rule = $item.Rule
|
||||
property = $item.Property
|
||||
value = $item.Value
|
||||
})
|
||||
}
|
||||
$jsonObj.applicabilityRules = $rules
|
||||
}
|
||||
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Sort-Object -Property Order)) {
|
||||
if (-not $customTable.Values -or ($customTable.Values | Measure-Object).Count -eq 0) { continue }
|
||||
|
||||
$tableKey = if ($customTable.LanguageId) { $customTable.LanguageId.Split('.')[-1] } else { "customTable" }
|
||||
$tableArr = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
foreach ($item in $customTable.Values) {
|
||||
$tableObj = [ordered]@{}
|
||||
foreach ($col in $customTable.Columns) {
|
||||
$colName = $col.Split('.')[-1]
|
||||
$tableObj[$colName] = "$($item.$colName)"
|
||||
}
|
||||
$tableArr.Add($tableObj)
|
||||
}
|
||||
$jsonObj[$tableKey] = $tableArr
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
$assignments = [System.Collections.Generic.List[object]]::new()
|
||||
$hasRawIntent = $null -ne $documentedObj.Assignments[0].RawIntent
|
||||
|
||||
foreach ($item in $documentedObj.Assignments) {
|
||||
if ($hasRawIntent) {
|
||||
$assignObj = [ordered]@{
|
||||
groupMode = $item.GroupMode
|
||||
group = $item.Group
|
||||
}
|
||||
if ($null -ne $item.Filter) { $assignObj.filter = $item.Filter }
|
||||
if ($null -ne $item.FilterMode) { $assignObj.filterMode = $item.FilterMode }
|
||||
if ($item.Settings) {
|
||||
$settingsObj = [ordered]@{}
|
||||
foreach ($key in $item.Settings.Keys) {
|
||||
if ($key -in @("Category","RawIntent")) { continue }
|
||||
$settingsObj[$key] = $item.Settings[$key]
|
||||
}
|
||||
$assignObj.settings = $settingsObj
|
||||
}
|
||||
}
|
||||
else {
|
||||
$assignObj = [ordered]@{ group = $item.Group }
|
||||
if ($item.PSObject.Properties.Name -contains "Filter") { $assignObj.filter = $item.Filter }
|
||||
if ($item.PSObject.Properties.Name -contains "FilterMode") { $assignObj.filterMode = $item.FilterMode }
|
||||
}
|
||||
$assignments.Add($assignObj)
|
||||
}
|
||||
$jsonObj.assignments = $assignments
|
||||
}
|
||||
|
||||
if (($documentedObj.Scripts | Measure-Object).Count -gt 0) {
|
||||
$scripts = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent) { continue }
|
||||
$scripts.Add([ordered]@{
|
||||
caption = $scriptItem.Caption
|
||||
content = $scriptItem.ScriptContent
|
||||
})
|
||||
}
|
||||
if ($scripts.Count -gt 0) { $jsonObj.scripts = $scripts }
|
||||
}
|
||||
|
||||
$script:jsonCurrentTypeObjects.Add($jsonObj)
|
||||
if ($script:jsonOutputType -ne "ObjectType") { $script:jsonAllObjects.Add($jsonObj) }
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-JsonPostProcessItems {
|
||||
$openFile = (Get-DocumentationOutputOption json "JSONOpenFile" $true) -eq $true
|
||||
|
||||
if ($script:jsonOutputType -eq "ObjectType") {
|
||||
if ($script:jsonCurrentTypeName -and $script:jsonCurrentTypeObjects.Count -gt 0) {
|
||||
Save-JsonTypeFile $script:jsonCurrentTypeName $script:jsonCurrentTypeObjects
|
||||
}
|
||||
Write-Log "Json documentation saved to folder: $($script:jsonDocumentPath)"
|
||||
}
|
||||
else {
|
||||
$jsonContent = ConvertTo-Json -InputObject @($script:jsonAllObjects) -Depth 20
|
||||
Save-DocumentationFile $jsonContent $script:jsonOutFile -OpenFile:$openFile
|
||||
}
|
||||
}
|
||||
|
||||
function Save-JsonTypeFile {
|
||||
param($typeName, $objects)
|
||||
|
||||
$safeTypeName = Remove-InvalidFileNameChars ($typeName.Replace(" ", "_"))
|
||||
$typeFileName = [IO.Path]::Combine($script:jsonDocumentPath, "$safeTypeName.json")
|
||||
$jsonContent = ConvertTo-Json -InputObject @($objects) -Depth 20
|
||||
Save-DocumentationFile $jsonContent $typeFileName
|
||||
Write-Log "Saved $($objects.Count) objects to $typeFileName"
|
||||
}
|
||||
|
||||
Invoke-InitializeJsonOutput
|
||||
@@ -0,0 +1,497 @@
|
||||
# Markdown output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result (see DocumentationOutputJson.ps1
|
||||
# header for the field list). Renders HTML-tabled markdown with CSS styling
|
||||
# from Internal/Documentation/Assets/DefaultMDStyle.css (or a user-supplied .css file).
|
||||
#
|
||||
# Differences vs old DocumentationMD.psm1:
|
||||
# - Drops V1 NewObjectGroup/NewObjectType hooks; V2 (string-arg) is registered
|
||||
# - Drops the extended/custom property selectors that read $global:cb*/$global:txt*
|
||||
# UI controls. Always uses DefaultDocumentationProperties or ('Name','Value').
|
||||
# Phase 2 [DocumentationContext] will reintroduce these via $ctx.Options.
|
||||
# - Drops the unused commented-out block at end of Invoke-MDPostProcessItems
|
||||
# - Uses the typed-object API: $PolicyObject.Name + $PolicyObject.PolicyType.Title
|
||||
# instead of Get-GraphObjectName $obj $objectType + $objectType.Title
|
||||
|
||||
function Invoke-InitializeMDOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "Markdown"
|
||||
Value = "md"
|
||||
# Path metadata (see DocumentationOutputHTML.ps1 header comment).
|
||||
# UI browse-button wiring lives in
|
||||
# UI/<backend>/ClassExtensions/DocumentationOutputMDUIExtension.ps1.
|
||||
PrimaryPathOption = "MDDocumentName"
|
||||
PathIsFolder = $false
|
||||
PreProcess = { Invoke-MDPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-MDNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-MDNewObjectType @args }
|
||||
Process = { Invoke-MDProcessItem @args }
|
||||
PostProcess = { Invoke-MDPostProcessItems @args }
|
||||
ProcessAllObjects = { Invoke-MDProcessAllObjects @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-MDProcessAllObjects {
|
||||
param($allObjectTypeObjects, $objectType)
|
||||
# Reserved for cross-object aggregation (e.g. consolidated ScopeTags table)
|
||||
}
|
||||
|
||||
function Invoke-MDPreProcessItems {
|
||||
$script:sectionAnchors = @()
|
||||
$script:totAnchors = @()
|
||||
$script:mdStrings = $null
|
||||
$script:currentItemFileName = $null
|
||||
|
||||
$defaultCSSFile = [IO.Path]::Combine($script:AppRootFolder, "Internal", "Documentation", "Assets", "DefaultMDStyle.css")
|
||||
$mdCssFile = Get-DocumentationOutputOption md "MDCSSFile" $defaultCSSFile
|
||||
$includeCss = (Get-DocumentationOutputOption md "MDIncludeCSS" $true) -eq $true
|
||||
|
||||
if (-not $mdCssFile) {
|
||||
Write-Log "CSS file not specified. Using default" 2
|
||||
$mdCssFile = $defaultCSSFile
|
||||
}
|
||||
elseif (-not [IO.File]::Exists($mdCssFile)) {
|
||||
Write-Log "CSS file $mdCssFile not found. Using default" 2
|
||||
$mdCssFile = $defaultCSSFile
|
||||
}
|
||||
|
||||
if ($includeCss -and [IO.File]::Exists($mdCssFile)) {
|
||||
Write-Log "Using CSS file $mdCssFile"
|
||||
$script:cssStyle = ([IO.File]::ReadAllText($mdCssFile)) + [Environment]::NewLine
|
||||
}
|
||||
else {
|
||||
Write-Log "CSS file $mdCssFile not found. No styles applied" 2
|
||||
$script:cssStyle = ""
|
||||
}
|
||||
|
||||
$fileName = Get-DocumentationOutputOption md "MDDocumentName" ""
|
||||
if (-not $fileName) { $fileName = "%MyDocuments%\%Organization%-%Date%.md" }
|
||||
$fileName = Expand-FileName $fileName
|
||||
|
||||
$script:outFile = $fileName
|
||||
$script:documentPath = [IO.Path]::GetDirectoryName($fileName)
|
||||
$script:outputType = Get-DocumentationOutputOption md "MDDocumentFileType" "Full"
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
Write-Log "Document one file for each object + index file"
|
||||
}
|
||||
else {
|
||||
Write-Log "Document one single file for all objects"
|
||||
$script:outputType = "Full"
|
||||
$script:mdStrings = [System.Text.StringBuilder]::new()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-MDPostProcessItems {
|
||||
$userName = $null
|
||||
$mail = ""
|
||||
$me = Get-CurrentUser
|
||||
if ($me) {
|
||||
if ($me.givenName -and $me.surname) {
|
||||
$userName = "$($me.givenName) $($me.surname)"
|
||||
}
|
||||
else {
|
||||
$userName = $me.displayName
|
||||
}
|
||||
if ($me.mail) { $mail = " ($($me.mail))" }
|
||||
}
|
||||
|
||||
$orgName = Get-CurrentOrganizationName
|
||||
|
||||
$title = Get-DocumentationOutputOption md "MDTitleProperty" "Intune documentation"
|
||||
if (-not $title) { $title = "Intune documentation" }
|
||||
|
||||
$mdContent = [System.Text.StringBuilder]::new()
|
||||
[void]$mdContent.AppendLine("# $title")
|
||||
[void]$mdContent.AppendLine("")
|
||||
[void]$mdContent.AppendLine("")
|
||||
|
||||
if (-not ((Get-DocumentationOption "SkipDocumentInfo" $false) -eq $true)) {
|
||||
if ($orgName) { [void]$mdContent.AppendLine("*Organization:* $orgName`n") }
|
||||
if ($userName) { [void]$mdContent.AppendLine("*Generated by:* $userName$mail`n") }
|
||||
|
||||
$skipDate = (Get-DocumentationOutputOption md "MDDocumentSkipDate" $false) -eq $true
|
||||
if (-not $skipDate) {
|
||||
[void]$mdContent.AppendLine("*Generated:* $((Get-Date).ToShortDateString()) $((Get-Date).ToLongTimeString())`n")
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:sectionAnchors.Count -gt 0) {
|
||||
[void]$mdContent.AppendLine("")
|
||||
[void]$mdContent.AppendLine("## Table of Contents")
|
||||
}
|
||||
|
||||
foreach ($header in $script:sectionAnchors) {
|
||||
$indent = [string]::new(" ", (($header.Level - 1) * 2))
|
||||
[void]$mdContent.AppendLine("$indent- [$($header.Name)]($($header.FileName)#$($header.Anchor))`n")
|
||||
}
|
||||
[void]$mdContent.AppendLine("")
|
||||
|
||||
$mdText = $script:cssStyle + $mdContent.ToString()
|
||||
if ($script:outputType -eq "Full" -and $script:mdStrings) {
|
||||
$mdText += $script:mdStrings.ToString()
|
||||
}
|
||||
|
||||
Save-DocumentationFile $mdText $script:outFile -OpenFile:((Get-DocumentationOutputOption md "MDOpenFile" $true) -eq $true)
|
||||
}
|
||||
|
||||
function Invoke-MDNewObjectGroup {
|
||||
param($groupId)
|
||||
Add-MDHeader (Get-DocObjectTypeString $groupId) -Level 1 -UseHTML
|
||||
}
|
||||
|
||||
function Invoke-MDNewObjectType {
|
||||
param($objectTypeName)
|
||||
Add-MDHeader $objectTypeName -Level 2 -UseHTML
|
||||
}
|
||||
|
||||
# Per-object file name for Object mode. Identity, not title: the policy's own
|
||||
# display name plus its type and id, the shape Get-HTMLObjectFileName uses.
|
||||
#
|
||||
# The display name alone was never unique. Five enrollment defaults - device
|
||||
# limit, platform restrictions, enrollment status page, Windows Hello for
|
||||
# Business, Windows Restore - are all called "All users and all devices", so
|
||||
# they all wrote All_users_and_all_devices.md and the last one won. Deriving the
|
||||
# name from the heading instead would not do either: the heading may be a
|
||||
# DocumentName override, and a file name has to identify the object, not
|
||||
# describe it. The type and id settle it. Spaces become underscores and the
|
||||
# suffix carries no brackets, because this name lands inside Markdown link
|
||||
# destinations.
|
||||
function Get-MDObjectFileName {
|
||||
param($PolicyObject)
|
||||
|
||||
$objName = if ($PolicyObject.Name) { [string]$PolicyObject.Name } else { 'Unnamed policy' }
|
||||
$id = if ($PolicyObject.Id) { [string]$PolicyObject.Id } else { $null }
|
||||
$typeId = if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.Id) { [string]$PolicyObject.PolicyType.Id } else { $null }
|
||||
$suffix = if ($typeId -and $id) { " $typeId-$id" }
|
||||
elseif ($id) { " $id" }
|
||||
else { '' }
|
||||
|
||||
# A policy name can run past 200 characters and the suffix adds up to
|
||||
# around 120 more - past what a path may hold, where the name-only file
|
||||
# still wrote. The suffix is the identity, so it is the name that gives way.
|
||||
$maxNameLength = 80
|
||||
if ($objName.Length -gt $maxNameLength) { $objName = $objName.Substring(0, $maxNameLength).TrimEnd() }
|
||||
|
||||
return (Remove-InvalidFileNameChars "$objName$suffix.md").Replace(' ', '_')
|
||||
}
|
||||
|
||||
function Invoke-MDProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
# A documented object may ask to be titled by something other than its display
|
||||
# name (see Get-DocumentationDisplayName). Headings and captions follow it; the
|
||||
# file name below deliberately does not.
|
||||
$objName = Get-DocumentationDisplayName $PolicyObject $documentedObj
|
||||
$script:docDisplayName = $objName
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
$script:totAnchors = @()
|
||||
$script:mdStrings = [System.Text.StringBuilder]::new()
|
||||
$script:currentItemFileName = "./$(Get-MDObjectFileName $PolicyObject)"
|
||||
}
|
||||
|
||||
Add-MDHeader $objName -Level 3 -UseHTML
|
||||
[void]$script:mdStrings.AppendLine("")
|
||||
|
||||
try {
|
||||
foreach ($tableType in @("BasicInfo","FilteredSettings")) {
|
||||
if ($tableType -eq "BasicInfo") {
|
||||
$properties = @("Name","Value")
|
||||
$lngId = "SettingDetails.basics"
|
||||
}
|
||||
else {
|
||||
if ($documentedObj.DefaultDocumentationProperties) {
|
||||
$properties = $documentedObj.DefaultDocumentationProperties
|
||||
}
|
||||
else {
|
||||
$properties = @("Name","Value")
|
||||
}
|
||||
$lngId = "TableHeaders.settings"
|
||||
}
|
||||
|
||||
# Custom tables with a negative Order belong ABOVE the settings
|
||||
# table: the portal shows a MAM app config's "Settings catalog"
|
||||
# blade above its "Settings" blade.
|
||||
if ($tableType -eq "FilteredSettings") {
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -lt 0 } | Sort-Object -Property Order)) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.$tableType | Measure-Object).Count -gt 0) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.$tableType $properties $lngId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.ComplianceActions @("Action","Schedule","MessageTemplate","EmailCC") "Category.complianceActionsLabel"
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.ApplicabilityRules @("Rule","Property","Value") "SettingDetails.applicabilityRules"
|
||||
}
|
||||
|
||||
Add-MDObjectScripts $documentedObj
|
||||
|
||||
# Negative Order already rendered above the settings table.
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -ge 0 } | Sort-Object -Property Order)) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
if ($documentedObj.Assignments[0].RawIntent) {
|
||||
$properties = @("GroupMode","Group","Filter","FilterMode")
|
||||
$settingsObj = $documentedObj.Assignments | Where-Object { $_.Settings -ne $null } | Select-Object -First 1
|
||||
if ($settingsObj) {
|
||||
foreach ($objProp in $settingsObj.Settings.Keys) {
|
||||
if ($objProp -in $properties) { continue }
|
||||
if ($objProp -in @("Category","RawIntent")) { continue }
|
||||
$properties += "Settings.$objProp"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$hasFilter = $false
|
||||
foreach ($a in $documentedObj.Assignments) {
|
||||
if ($a.PSObject.Properties.Name -contains "FilterMode") { $hasFilter = $true; break }
|
||||
}
|
||||
$properties = @("Group")
|
||||
if ($hasFilter) { $properties += @("Filter","FilterMode") }
|
||||
}
|
||||
|
||||
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.Assignments $properties "TableHeaders.assignments" -AddCategories
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
$perObjectText = $script:cssStyle + $script:mdStrings.ToString()
|
||||
$fileName = Join-Path $script:documentPath $script:currentItemFileName
|
||||
Save-DocumentationFile $perObjectText $fileName
|
||||
$script:mdStrings = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Add-MDTableItems {
|
||||
param(
|
||||
$PolicyObject,
|
||||
[string]$TypeTitle,
|
||||
$Items,
|
||||
[string[]]$Properties,
|
||||
[string]$LngId,
|
||||
[switch]$AddCategories,
|
||||
[switch]$AddSubcategories,
|
||||
$CaptionOverride
|
||||
)
|
||||
|
||||
$objName = Get-DocCaptionName $PolicyObject
|
||||
if ($CaptionOverride) {
|
||||
$caption = $CaptionOverride
|
||||
}
|
||||
elseif ($LngId) {
|
||||
$caption = "$((Get-LanguageString $LngId)) - $objName"
|
||||
}
|
||||
else {
|
||||
$caption = "$objName ($TypeTitle)"
|
||||
}
|
||||
|
||||
$tableText = [System.Text.StringBuilder]::new()
|
||||
[void]$tableText.AppendLine("<table class='table-settings'>")
|
||||
[void]$tableText.AppendLine("<tr class='table-header1'>")
|
||||
|
||||
$columnCount = 0
|
||||
foreach ($prop in $Properties) {
|
||||
[void]$tableText.AppendLine("<td>$((Invoke-DocTranslateColumnHeader $prop.Split('.')[-1]))</td>")
|
||||
$columnCount++
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
|
||||
$curCategory = ""
|
||||
$curSubCategory = ""
|
||||
|
||||
foreach ($itemObj in $Items) {
|
||||
$additionalRowClass = ""
|
||||
|
||||
if ($itemObj.Category -and $curCategory -ne $itemObj.Category -and $AddCategories) {
|
||||
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level1'>$((Set-MDText $itemObj.Category))</td></tr>")
|
||||
$curCategory = $itemObj.Category
|
||||
$curSubCategory = ""
|
||||
}
|
||||
|
||||
if ($itemObj.SubCategory -and $curSubCategory -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level2'>$((Set-MDText $itemObj.SubCategory))</td></tr>")
|
||||
$curSubCategory = $itemObj.SubCategory
|
||||
}
|
||||
|
||||
if ($itemObj.PropertyIndex -is [int] -and $itemObj.PropertyIndex -eq 1) {
|
||||
$additionalRowClass = "row-new-property"
|
||||
}
|
||||
|
||||
try {
|
||||
[void]$tableText.AppendLine("<tr class='$additionalRowClass'>")
|
||||
$curCol = 1
|
||||
foreach ($prop in $Properties) {
|
||||
try {
|
||||
$propArr = $prop.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
|
||||
if ($propName -eq "Value" -and ($itemObj.FullValueTable | Measure-Object).Count -gt 0) {
|
||||
[void]$tableText.AppendLine("<td><table class='table-value'><tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$tableText.AppendLine("<td class='table-header1'>$($colProp.Name)</td>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
foreach ($rowVal in $itemObj.FullValueTable) {
|
||||
[void]$tableText.AppendLine("<tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$tableText.AppendLine("<td>$($rowVal."$($colProp.Name)")</td>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</table></td>")
|
||||
}
|
||||
else {
|
||||
$style = ""
|
||||
if ($curCol -eq 1 -and $itemObj.Level) {
|
||||
try {
|
||||
$level = [int]$itemObj.Level
|
||||
$style = " style='padding-left:$((5 + ($level * 5)))px !important;'"
|
||||
} catch {}
|
||||
}
|
||||
[void]$tableText.AppendLine("<td class='property-column$curCol'$style>$((Set-MDText $tmpObj.$propName -CodeBlock))</td>")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add property value for $prop" $_.Exception
|
||||
}
|
||||
$curCol++
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to process property" 2
|
||||
}
|
||||
finally {
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
}
|
||||
}
|
||||
|
||||
[void]$tableText.AppendLine("</table>")
|
||||
Add-MDText $tableText.ToString()
|
||||
Add-MDHeader $caption -Level 6 -ToT -AddParagraph
|
||||
}
|
||||
|
||||
function Add-MDText {
|
||||
param([string]$Text, [switch]$AddParagraph)
|
||||
[void]$script:mdStrings.AppendLine($Text)
|
||||
if ($AddParagraph) { [void]$script:mdStrings.AppendLine("") }
|
||||
}
|
||||
|
||||
function Set-MDText {
|
||||
param([string]$Text, [switch]$CodeBlock)
|
||||
|
||||
if ($null -eq $Text) { return }
|
||||
|
||||
$txtSummary = ""
|
||||
$textOut = ""
|
||||
|
||||
if ($Text -and $Text.Length -gt 250) {
|
||||
$summaryMax = 40
|
||||
$idx = $Text.IndexOfAny(@("`r","`n"))
|
||||
if ($idx -gt 10 -and $idx -lt 50) { $summaryMax = $idx }
|
||||
$txtSummary = $Text.Substring(0, $summaryMax)
|
||||
}
|
||||
|
||||
if ($CodeBlock) {
|
||||
$trim = $Text.Trim()
|
||||
if ($trim.StartsWith("<?xml") -or $trim.StartsWith("<xml") -or ($trim.StartsWith("<") -and $trim.EndsWith(">"))) {
|
||||
$nl = [Environment]::NewLine
|
||||
$textOut = "$nl$nl``````xml$nl$Text$nl```````$nl$nl"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $CodeBlock -or -not $textOut) {
|
||||
$t = $Text.Replace("|", '`|')
|
||||
$t = $t.Replace("*", '`*')
|
||||
$t = $t.Replace("`$", '`$')
|
||||
$t = $t.Replace("`r`n", "<br />")
|
||||
$textOut = $t.Replace("`n", "<br />")
|
||||
}
|
||||
|
||||
if ($txtSummary) {
|
||||
"<details class='description'><summary data-open='Minimize' data-close='$txtSummary...expand'></summary>$textOut</details>"
|
||||
}
|
||||
else {
|
||||
$textOut
|
||||
}
|
||||
}
|
||||
|
||||
function Add-MDHeader {
|
||||
param(
|
||||
[string]$Text,
|
||||
[int]$Level = 1,
|
||||
[switch]$AddParagraph,
|
||||
[switch]$UseHTML,
|
||||
[switch]$ToT,
|
||||
[switch]$SkipTOC
|
||||
)
|
||||
|
||||
if ($script:mdStrings) {
|
||||
$prefix = ""
|
||||
if ($ToT) { $prefix = "Table $($script:totAnchors.Count + 1). " }
|
||||
|
||||
if ($UseHTML) {
|
||||
if ($ToT) { $sectionAnchor = "table-$($script:totAnchors.Count + 1)" }
|
||||
else { $sectionAnchor = "section-$($script:sectionAnchors.Count + 1)" }
|
||||
|
||||
[void]$script:mdStrings.AppendLine("<h$Level id=`"$prefix$sectionAnchor`">$Text</h$Level>")
|
||||
}
|
||||
else {
|
||||
$Text = "$prefix$Text"
|
||||
$sectionAnchor = $Text.ToLower().Replace(" ", "-").Replace("[","").Replace("]","")
|
||||
$mdHeader = [string]::new('#', $Level)
|
||||
[void]$script:mdStrings.AppendLine("$mdHeader $Text")
|
||||
}
|
||||
$fileName = $script:currentItemFileName
|
||||
}
|
||||
else {
|
||||
$sectionAnchor = $null
|
||||
$fileName = $null
|
||||
}
|
||||
|
||||
if ($ToT) {
|
||||
$script:totAnchors += [PSCustomObject]@{
|
||||
Name = $Text; Anchor = $sectionAnchor; FileName = $fileName; Level = $Level
|
||||
}
|
||||
}
|
||||
elseif (-not $SkipTOC) {
|
||||
$script:sectionAnchors += [PSCustomObject]@{
|
||||
Name = $Text; Anchor = $sectionAnchor; FileName = $fileName; Level = $Level
|
||||
}
|
||||
}
|
||||
|
||||
if ($AddParagraph) { [void]$script:mdStrings.AppendLine("`n") }
|
||||
}
|
||||
|
||||
function Add-MDObjectScripts {
|
||||
param($documentedObj)
|
||||
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent -or -not $scriptItem.Caption) { continue }
|
||||
[void]$script:mdStrings.AppendLine("~~~powershell")
|
||||
[void]$script:mdStrings.AppendLine($scriptItem.ScriptContent)
|
||||
[void]$script:mdStrings.AppendLine("~~~")
|
||||
Add-MDHeader $scriptItem.Caption -Level 6 -SkipTOC -AddParagraph
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeMDOutput
|
||||
@@ -0,0 +1,929 @@
|
||||
# Word output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result (see DocumentationOutputJson.ps1
|
||||
# header for the field list). Writes a .docx, .docm/.xml (strict), or .pdf via
|
||||
# Microsoft.Office.Interop.Word COM automation. Word must be installed locally.
|
||||
#
|
||||
# https://docs.microsoft.com/en-us/office/vba/api/overview/word
|
||||
#
|
||||
# Differences vs old DocumentationWord.psm1:
|
||||
# - Uses the typed-object API: $PolicyObject.Name + $PolicyObject.PolicyType
|
||||
# instead of Get-GraphObjectName / Get-ObjectTypeString taking $objectType
|
||||
# - V1 NewObjectGroup/NewObjectType ($obj-arg) replaced by V2 (string-arg);
|
||||
# the old V1 versions were unreachable (registration used V2)
|
||||
# - The "Attach raw object JSON" Word feature is stubbed out — it depends on
|
||||
# Export-GraphObject which lives in old MSGraph.psm1 and hasn't been ported
|
||||
# to the new project. A "feature unavailable" log message replaces it; phase 2
|
||||
# can re-enable once the equivalent exporter is wired up.
|
||||
# - Invoke-WordProcessAllObjects ScopeTags consolidated table is stubbed too,
|
||||
# same reason as the HTML provider (Get-TableObjects deferred to phase 2).
|
||||
# - All other COM logic — cover page, ToC, building blocks, style hashtable,
|
||||
# option snapshot/restore, save & close — preserved verbatim.
|
||||
|
||||
# Load the Word primary interop assembly. Deliberately NOT called at module
|
||||
# import: Add-Type -AssemblyName fails on PS7 (it resolves against the current
|
||||
# directory, not the GAC), so this always fell through to a recursive scan of
|
||||
# %windir%\assembly\GAC_MSIL - ~77ms on every single Import-Module, for a
|
||||
# feature most sessions never use. It also put an assembly in the AppDomain
|
||||
# whose GetExportedTypes() throws, which is one of the two things that used to
|
||||
# take down Avalonia's XAML loader (see Host.SanitizeXamlTypeSystem).
|
||||
#
|
||||
# Idempotent: returns $true as soon as the interop types are resolvable.
|
||||
#
|
||||
# The readiness probe is WdSaveFormat, not the Application coclass. Everything
|
||||
# this provider needs from the interop assembly is enums - the Word instance
|
||||
# itself comes from late-bound `New-Object -ComObject Word.Application` - and on
|
||||
# PS7 the enums resolve while the coclass does not. Probing Application would
|
||||
# therefore report "not loaded" forever on PS7, which is also why the previous
|
||||
# code's short-circuit never fired there and re-scanned the GAC on every import.
|
||||
function Initialize-WordInteropAssembly {
|
||||
if ("Microsoft.Office.Interop.Word.WdSaveFormat" -as [Type]) { return $true }
|
||||
|
||||
try {
|
||||
Add-Type -AssemblyName Microsoft.Office.Interop.Word -ErrorAction Stop
|
||||
if ("Microsoft.Office.Interop.Word.WdSaveFormat" -as [Type]) { return $true }
|
||||
}
|
||||
catch { }
|
||||
|
||||
try {
|
||||
$wordFile = Get-ChildItem -Path "$($env:windir)\assembly\GAC_MSIL" -Filter "Microsoft.Office.Interop.Word.dll" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($wordFile -and $wordFile.Exists) {
|
||||
Add-Type -Path $wordFile.FullName
|
||||
if ("Microsoft.Office.Interop.Word.WdSaveFormat" -as [Type]) { return $true }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add Word Interop type. Cannot create Word documents. Verify that Word is installed properly." $_.Exception
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-LogError "Word Interop type not found. Cannot create Word documents. Verify that Word is installed properly." $null
|
||||
return $false
|
||||
}
|
||||
|
||||
function Invoke-InitializeWordOutput {
|
||||
# Word output relies on Microsoft.Office.Interop.Word COM automation, which only
|
||||
# exists on Windows with Word installed. Skip the provider entirely elsewhere.
|
||||
if (-not $script:IsWindowsOS) {
|
||||
Write-Log "Word documentation output is not available on this platform (requires Windows + Word). Skipping."
|
||||
return
|
||||
}
|
||||
|
||||
# Registration stays gated on Word being installed, as before - but probed by
|
||||
# reading the COM registration straight out of the registry, which loads
|
||||
# nothing into the AppDomain. [Type]::GetTypeFromProgID looks like the natural
|
||||
# call here and is correct on PS7, but on PS5.1 the .NET Framework resolves a
|
||||
# ProgID to its primary interop assembly and loads it - which would reintroduce
|
||||
# exactly the eager load this is meant to remove, on the one shell where the
|
||||
# old code's short-circuit actually worked.
|
||||
#
|
||||
# The interop itself is loaded lazily by Invoke-WordActivate, i.e. only when a
|
||||
# documentation run actually selects Word output.
|
||||
$wordRegistered = (Test-Path 'HKLM:\SOFTWARE\Classes\Word.Application') -or
|
||||
(Test-Path 'HKCU:\SOFTWARE\Classes\Word.Application')
|
||||
if (-not $wordRegistered) {
|
||||
Write-Log "Word is not registered on this machine. Word documentation output will not be available." 2
|
||||
return
|
||||
}
|
||||
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "Word"
|
||||
Value = "word"
|
||||
# Path metadata (see DocumentationOutputHTML.ps1 header comment).
|
||||
# UI browse-button wiring lives in
|
||||
# UI/<backend>/ClassExtensions/DocumentationOutputWordUIExtension.ps1.
|
||||
PrimaryPathOption = "WordDocumentName"
|
||||
PathIsFolder = $false
|
||||
Activate = { Invoke-WordActivate @args }
|
||||
PreProcess = { Invoke-WordPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-WordNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-WordNewObjectType @args }
|
||||
Process = { Invoke-WordProcessItem @args }
|
||||
PostProcess = { Invoke-WordPostProcessItems @args }
|
||||
ProcessAllObjects = { Invoke-WordProcessAllObjects @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-WordActivate {
|
||||
# Lazy load point for the interop assembly. Activate is the first lifecycle
|
||||
# hook the engine runs for a selected provider, so this happens only when a
|
||||
# documentation run actually asks for Word output. Throwing here is
|
||||
# deliberate: the engine wraps Activate in its $recordFailure handler, so the
|
||||
# run reports "Activate failed for Word: ..." instead of failing later and
|
||||
# less clearly when Process touches an interop enum.
|
||||
if (-not (Initialize-WordInteropAssembly)) {
|
||||
throw "Word Interop assembly could not be loaded. Cannot create Word documents. Verify that Word is installed properly."
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WordPreProcessItems {
|
||||
# Validate Limit-mode bounds
|
||||
$script:limitMaxValue = 100
|
||||
$script:truncateValueLength = $script:limitMaxValue
|
||||
|
||||
if ((Get-DocumentationOutputOption word "WordDocumentationLevel" "full") -eq "limited") {
|
||||
$maxText = Get-DocumentationOutputOption word "WordDocumentationLimitMaxLength" ""
|
||||
$truncateText = Get-DocumentationOutputOption word "WordDocumentationLimitTruncateLength" ""
|
||||
|
||||
if ($maxText) {
|
||||
try { $script:limitMaxValue = [int]::Parse($maxText) }
|
||||
catch { Write-LogError "Failed to parse '$maxText' to int. Max value length will be set to 100." $_.Exception }
|
||||
}
|
||||
if ($truncateText) {
|
||||
try { $script:truncateValueLength = [int]::Parse($truncateText) }
|
||||
catch { Write-LogError "Failed to parse '$truncateText' to int. Truncate length will be set to $script:limitMaxValue." $_.Exception }
|
||||
}
|
||||
|
||||
if ($script:limitMaxValue -lt 20) {
|
||||
Write-Log "Max value length must be 20 or more. Changed to 0" 2
|
||||
$script:limitMaxValue = 0
|
||||
}
|
||||
if ($script:truncateValueLength -lt 0) {
|
||||
Write-Log "Truncate length must be 0 or more. Changed to 0" 2
|
||||
$script:truncateValueLength = 0
|
||||
}
|
||||
elseif ($script:truncateValueLength -gt $script:limitMaxValue) {
|
||||
Write-Log "Truncate length cannot be larger than Max value length. Changed to: $script:limitMaxValue" 2
|
||||
$script:truncateValueLength = $script:limitMaxValue
|
||||
}
|
||||
}
|
||||
|
||||
# Create Word COM app
|
||||
try {
|
||||
$script:wordApp = New-Object -ComObject Word.Application
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to create Word App object. Word documentation aborted..." $_.Exception
|
||||
return $false
|
||||
}
|
||||
|
||||
# Performance: suppress UI redraw and background processing while filling the document.
|
||||
# Application-level Options persist to the user profile, so we snapshot and restore them in PostProcess.
|
||||
$script:wordApp.ScreenUpdating = $false
|
||||
$script:wordApp.DisplayAlerts = 0 # wdAlertsNone
|
||||
|
||||
$script:wordOptionsBackup = $null
|
||||
try {
|
||||
$script:wordOptionsBackup = @{
|
||||
Pagination = $script:wordApp.Options.Pagination
|
||||
CheckGrammarAsYouType = $script:wordApp.Options.CheckGrammarAsYouType
|
||||
CheckSpellingAsYouType = $script:wordApp.Options.CheckSpellingAsYouType
|
||||
BackgroundSave = $script:wordApp.Options.BackgroundSave
|
||||
}
|
||||
$script:wordApp.Options.Pagination = $false
|
||||
$script:wordApp.Options.CheckGrammarAsYouType = $false
|
||||
$script:wordApp.Options.CheckSpellingAsYouType = $false
|
||||
$script:wordApp.Options.BackgroundSave = $false
|
||||
}
|
||||
catch { }
|
||||
|
||||
$template = Get-DocumentationOutputOption word "WordDocumentTemplate" ""
|
||||
if ($template) {
|
||||
try {
|
||||
$script:doc = $script:wordApp.Documents.Add($template)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to create document based on template: $template" $_.Exception
|
||||
}
|
||||
}
|
||||
else {
|
||||
$script:doc = $script:wordApp.Documents.Add()
|
||||
}
|
||||
|
||||
# Get BuiltIn properties
|
||||
$script:builtInProps = [System.Collections.Generic.List[object]]::new()
|
||||
$script:doc.BuiltInDocumentProperties | ForEach-Object {
|
||||
$name = [System.__ComObject].InvokeMember("name", [System.Reflection.BindingFlags]::GetProperty, $null, $_, $null)
|
||||
$value = $null
|
||||
try { $value = [System.__ComObject].InvokeMember("value", [System.Reflection.BindingFlags]::GetProperty, $null, $_, $null) } catch {}
|
||||
if ($name) {
|
||||
$script:builtInProps.Add([PSCustomObject]@{ Name = $name; Value = $value })
|
||||
}
|
||||
}
|
||||
|
||||
# Get Custom properties
|
||||
$script:customProps = [System.Collections.Generic.List[object]]::new()
|
||||
$script:doc.CustomDocumentProperties | ForEach-Object {
|
||||
$name = [System.__ComObject].InvokeMember("name", [System.Reflection.BindingFlags]::GetProperty, $null, $_, $null)
|
||||
$value = $null
|
||||
try { $value = [System.__ComObject].InvokeMember("value", [System.Reflection.BindingFlags]::GetProperty, $null, $_, $null) } catch {}
|
||||
if ($name) {
|
||||
$script:customProps.Add([PSCustomObject]@{ Name = $name; Value = $value })
|
||||
}
|
||||
}
|
||||
|
||||
# Style cache: O(1) lookup by NameLocal (replaces per-call linear scan in Get-DocStyle / Set-DocObjectStyle)
|
||||
$script:wordStyles = @{}
|
||||
$script:doc.Styles | ForEach-Object {
|
||||
if ($_.NameLocal -and -not $script:wordStyles.ContainsKey($_.NameLocal)) {
|
||||
$script:wordStyles[$_.NameLocal] = [PSCustomObject]@{
|
||||
Name = $_.NameLocal; Type = $_.Type; Style = $_
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Built-in style cache: same O(1) treatment for the ~376 enum names
|
||||
$script:builtinStyles = @{}
|
||||
foreach ($builtinName in [Enum]::GetNames([Microsoft.Office.Interop.Word.wdBuiltinStyle])) {
|
||||
$script:builtinStyles[$builtinName] = $true
|
||||
}
|
||||
|
||||
if (-not $template) {
|
||||
$script:doc.Application.Templates.LoadBuildingBlocks()
|
||||
$bb = $script:doc.Application.Templates | Where-Object { $_.Name -eq 'Built-In Building Blocks.dotx' }
|
||||
if ($bb) {
|
||||
$coverPageName = Get-DocumentationOutputOption word "WordCoverPage" "Ion (Dark)"
|
||||
if (-not $coverPageName) { $coverPageName = 'Ion (Dark)' }
|
||||
|
||||
try {
|
||||
$blocks = @()
|
||||
for ($i = 1; $i -le $bb.BuildingBlockEntries.Count; $i++) {
|
||||
$blocks += $bb.BuildingBlockEntries.Item($i)
|
||||
}
|
||||
$coverPages = ($blocks | Where-Object { $_.Type.Index -eq 2 } | Select-Object Name | Sort-Object -Property Name).Name
|
||||
|
||||
if (($coverPages | Measure-Object).Count -gt 0) {
|
||||
if ($coverPageName -notin $coverPages) {
|
||||
Write-Log "$coverPageName not found in available Cover Page list. Using: $($coverPages[0])"
|
||||
Write-Log "Available Cover Pages: $($coverPages -join ',')"
|
||||
$coverPageName = $coverPages[0]
|
||||
}
|
||||
else {
|
||||
Write-Log "Add Cover Page: $coverPageName"
|
||||
}
|
||||
}
|
||||
|
||||
$coverPage = $bb.BuildingBlockEntries.Item($coverPageName)
|
||||
$coverPage.Insert($script:wordApp.Selection.Range, $true) | Out-Null
|
||||
$script:wordApp.Selection.InsertNewPage()
|
||||
}
|
||||
catch { Write-LogError "Failed to create Cover Page" $_.Exception }
|
||||
|
||||
try {
|
||||
$coverPageProps = $script:doc.CustomXMLParts | Where-Object { $_.NamespaceURI -match "coverPageProps$" }
|
||||
if ($coverPageProps) {
|
||||
Write-Log "Available Cover Page properties for $($coverPageName): $((([xml]$coverPageProps.DocumentElement.XML).ChildNodes[0].ChildNodes).Name -join ',')"
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try {
|
||||
$script:doc.TablesOfContents.Add($script:wordApp.Selection.Range) | Out-Null
|
||||
$script:wordApp.Selection.InsertNewPage()
|
||||
}
|
||||
catch { Write-LogError "Failed to create Table of Contents" $_.Exception }
|
||||
}
|
||||
}
|
||||
else {
|
||||
Invoke-DocGoToEnd
|
||||
$script:wordApp.Selection.InsertNewPage()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WordPostProcessItems {
|
||||
$userName = $null
|
||||
$me = Get-CurrentUser
|
||||
if ($me) {
|
||||
if ($me.givenName -and $me.surname) {
|
||||
$userName = "$($me.givenName) $($me.surname)"
|
||||
}
|
||||
else {
|
||||
$userName = $me.displayName
|
||||
}
|
||||
}
|
||||
|
||||
$titleProp = Get-DocumentationOutputOption word "WordTitleProperty" "Intune documentation"
|
||||
$subjectProp = Get-DocumentationOutputOption word "WordSubjectProperty" "Intune documentation"
|
||||
if (-not $titleProp) { $titleProp = "Intune documentation" }
|
||||
if (-not $subjectProp) { $subjectProp = "Intune documentation" }
|
||||
|
||||
Set-WordDocBuiltInProperty "wdPropertyTitle" $titleProp
|
||||
Set-WordDocBuiltInProperty "wdPropertySubject" $subjectProp
|
||||
# Author + Company are the "who generated this" document info. Word writes them
|
||||
# as built-in file metadata (not a visible top-of-document block like the other
|
||||
# providers), but they are the same info the generic SkipDocumentInfo flag hides.
|
||||
if (-not ((Get-DocumentationOption "SkipDocumentInfo" $false) -eq $true)) {
|
||||
Set-WordDocBuiltInProperty "wdPropertyAuthor" $userName
|
||||
$orgName = Get-CurrentOrganizationName
|
||||
if ($orgName) {
|
||||
Set-WordDocBuiltInProperty "wdPropertyCompany" $orgName
|
||||
}
|
||||
}
|
||||
Set-WordDocBuiltInProperty "wdPropertyKeywords" "Intune,Endpoint Manager,MEM"
|
||||
|
||||
try {
|
||||
$controls = Get-DocumentationOutputOption word "WordContentControls" ""
|
||||
foreach ($ccObj in $controls.Split(';')) {
|
||||
$ccName, $ccVal = $ccObj.Split('=')
|
||||
Set-WordContentControlText $ccName $ccVal
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
foreach ($field in @("Fields","TablesOfContents","TablesOfFigures","TablesOfAuthorities")) {
|
||||
try { $script:doc.$field | ForEach-Object { $_.Update() | Out-Null } }
|
||||
catch { Write-LogError "Failed to update document $field" $_.Exception }
|
||||
}
|
||||
|
||||
# Restore Application Options before saving so user settings aren't permanently changed.
|
||||
try {
|
||||
if ($script:wordOptionsBackup) {
|
||||
$script:wordApp.Options.Pagination = $script:wordOptionsBackup.Pagination
|
||||
$script:wordApp.Options.CheckGrammarAsYouType = $script:wordOptionsBackup.CheckGrammarAsYouType
|
||||
$script:wordApp.Options.CheckSpellingAsYouType = $script:wordOptionsBackup.CheckSpellingAsYouType
|
||||
$script:wordApp.Options.BackgroundSave = $script:wordOptionsBackup.BackgroundSave
|
||||
}
|
||||
$script:wordApp.ScreenUpdating = $true
|
||||
$script:doc.Repaginate()
|
||||
}
|
||||
catch { }
|
||||
|
||||
$formatStr = Get-DocumentationOutputOption word "WordDocumentFormat" "wdFormatDocumentDefault"
|
||||
if ($formatStr -eq "pdf") { $formatStr = "wdFormatPDF" }
|
||||
elseif ($formatStr -eq "docx") { $formatStr = "wdFormatDocumentDefault" }
|
||||
Write-Log "Using document format: $formatStr"
|
||||
$format = $null
|
||||
try {
|
||||
$format = [Microsoft.Office.Interop.Word.WdSaveFormat]$formatStr
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Document format validation failed; defaulting to wdFormatDocumentDefault" $_.Exception
|
||||
$format = [Microsoft.Office.Interop.Word.WdSaveFormat]::wdFormatDocumentDefault
|
||||
}
|
||||
|
||||
$fileName = Get-DocumentationOutputOption word "WordDocumentName" ""
|
||||
if (-not $fileName) { $fileName = "%MyDocuments%\%Organization%-%Date%.docx" }
|
||||
$fileName = Expand-FileName $fileName
|
||||
|
||||
if ($format -eq [Microsoft.Office.Interop.Word.WdSaveFormat]::wdFormatPDF -and $fileName -notlike "*.pdf") {
|
||||
$fileName = [IO.Path]::ChangeExtension($fileName, ".pdf")
|
||||
}
|
||||
|
||||
try {
|
||||
$script:doc.SaveAs2([ref]$fileName, [ref]$format)
|
||||
Write-Log "Document $fileName saved successfully"
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to save file $fileName" $_.Exception
|
||||
}
|
||||
|
||||
try {
|
||||
$openDocSetting = Get-DocumentationOutputOption word "WordOpenDocument" "true"
|
||||
$openDoc = ($openDocSetting -ne "false") -and ($openDocSetting -ne $false)
|
||||
# Only pop Word visible in interactive UI mode; headless / silent / bulk runs
|
||||
# close it (the .docx is already saved). ($global:hideUI was a dead old-project
|
||||
# global, never assigned -> Word always opened, even during automation.)
|
||||
$hideUI = (Get-CacheObject "ShowUI") -ne $true
|
||||
if ($openDoc -and -not $hideUI) {
|
||||
$script:wordApp.Visible = $true
|
||||
$script:wordApp.WindowState = [Microsoft.Office.Interop.Word.WdWindowState]::wdWindowStateMaximize
|
||||
$script:wordApp.Activate()
|
||||
}
|
||||
else {
|
||||
$script:doc.Close([Microsoft.Office.Interop.Word.WdSaveOptions]::wdDoNotSaveChanges)
|
||||
$script:wordApp.Quit()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to close the Word application" $_.Exception
|
||||
}
|
||||
finally {
|
||||
try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($script:doc) } catch { }
|
||||
try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($script:wordApp) } catch { }
|
||||
[GC]::Collect()
|
||||
[GC]::WaitForPendingFinalizers()
|
||||
}
|
||||
}
|
||||
|
||||
function Set-WordContentControlText {
|
||||
param([string]$ControlName, $Value)
|
||||
|
||||
if (-not $ControlName) { return }
|
||||
|
||||
try {
|
||||
$ctrl = $script:doc.SelectContentControlsByTitle($ControlName)
|
||||
if ($ctrl) {
|
||||
Write-LogDebug "Update ContentControl $ControlName (Type: $($ctrl[1].Type))"
|
||||
if ($ctrl[1].Type -eq 6) {
|
||||
if ($ctrl[1].DateDisplayFormat) {
|
||||
$ctrl[1].Range.Text = (Get-Date).ToString($ctrl[1].DateDisplayFormat)
|
||||
}
|
||||
else {
|
||||
$ctrl[1].Range.Text = (Get-Date).ToShortDateString()
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (-not $Value) { return }
|
||||
$ctrl[1].Range.Text = $Value
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to set ContentControl $ControlName" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WordNewObjectGroup {
|
||||
param($groupId)
|
||||
|
||||
$header1 = Get-DocumentationOutputOption word "WordHeader1Style" "Heading 1"
|
||||
if (-not $header1) { $header1 = "Heading 1" }
|
||||
Add-DocText (Get-DocObjectTypeString $groupId) $header1
|
||||
}
|
||||
|
||||
function Invoke-WordNewObjectType {
|
||||
param($objectTypeName)
|
||||
|
||||
$script:objectHeaderLevel = 2
|
||||
Add-DocText $objectTypeName (Get-ObjectLevelHeader)
|
||||
$script:objectHeaderLevel = 3
|
||||
}
|
||||
|
||||
function Get-ObjectLevelHeader {
|
||||
if ($script:objectHeaderLevel -eq 3) {
|
||||
$h3 = Get-DocumentationOutputOption word "WordHeader3Style" ""
|
||||
if ($h3) { return $h3 }
|
||||
}
|
||||
$h2 = Get-DocumentationOutputOption word "WordHeader2Style" "Heading 2"
|
||||
if (-not $h2) { $h2 = "Heading 2" }
|
||||
return $h2
|
||||
}
|
||||
|
||||
function Invoke-WordProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
# A documented object may ask to be titled by something other than its display
|
||||
# name (see Get-DocumentationDisplayName). Headings and captions follow it; the
|
||||
# file name below deliberately does not.
|
||||
$objName = Get-DocumentationDisplayName $PolicyObject $documentedObj
|
||||
$script:docDisplayName = $objName
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
Add-DocText $objName (Get-ObjectLevelHeader)
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
|
||||
$propMode = Get-DocumentationOutputOption word "WordExportProperties" "simple"
|
||||
$customProps = Get-DocumentationOutputOption word "WordCustomDisplayProperties" ""
|
||||
$docLevel = Get-DocumentationOutputOption word "WordDocumentationLevel" "full"
|
||||
$addCategories = (Get-DocumentationOutputOption word "WordAddCategories" $true) -eq $true
|
||||
$addSubCats = (Get-DocumentationOutputOption word "WordAddSubCategories" $true) -eq $true
|
||||
$attachJson = (Get-DocumentationOutputOption word "WordAttachJsonFile" $false) -eq $true
|
||||
|
||||
try {
|
||||
foreach ($tableType in @("BasicInfo","FilteredSettings")) {
|
||||
if ($tableType -eq "BasicInfo") {
|
||||
$properties = @("Name","Value")
|
||||
}
|
||||
elseif ($propMode -eq 'extended' -and $documentedObj.DisplayProperties) {
|
||||
$properties = @("Name","Value","Description")
|
||||
}
|
||||
elseif ($propMode -eq 'custom' -and $customProps) {
|
||||
$properties = @()
|
||||
foreach ($prop in $customProps.Split(",")) {
|
||||
$propInfo = $prop.Split('=')
|
||||
if (($propInfo | Measure-Object).Count -gt 1) {
|
||||
$properties += $propInfo[0]
|
||||
Set-DocColumnHeaderLanguageId $propInfo[0] $propInfo[1]
|
||||
}
|
||||
else {
|
||||
$properties += $prop
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($documentedObj.DefaultDocumentationProperties) {
|
||||
$properties = $documentedObj.DefaultDocumentationProperties
|
||||
}
|
||||
else {
|
||||
$properties = @("Name","Value")
|
||||
}
|
||||
}
|
||||
|
||||
if ($docLevel -eq "basic" -and $tableType -ne "BasicInfo") { continue }
|
||||
|
||||
# Custom tables with a negative Order belong ABOVE the settings
|
||||
# table: the portal shows a MAM app config's "Settings catalog"
|
||||
# blade above its "Settings" blade.
|
||||
if ($tableType -eq "FilteredSettings") {
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -lt 0 } | Sort-Object -Property Order)) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns -LngId $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.$tableType | Measure-Object).Count -gt 0) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $documentedObj.$tableType $properties -AddCategories:$addCategories -AddSubcategories:$addSubCats -ForceFullValue:($tableType -eq "BasicInfo")
|
||||
}
|
||||
}
|
||||
|
||||
if ($docLevel -ne "basic") {
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $documentedObj.ComplianceActions @("Action","Schedule","MessageTemplate","EmailCC") -LngId "Category.complianceActionsLabel"
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $documentedObj.ApplicabilityRules @("Rule","Property","Value") -LngId "SettingDetails.applicabilityRules"
|
||||
}
|
||||
|
||||
Add-DocObjectScripts $documentedObj
|
||||
|
||||
# Negative Order already rendered above the settings table.
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -ge 0 } | Sort-Object -Property Order)) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns -LngId $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
$settingProps = $null
|
||||
if ($documentedObj.Assignments[0].RawIntent) {
|
||||
$properties = @("GroupMode","Group","Filter","FilterMode")
|
||||
$settingProps = @("Filter","FilterMode")
|
||||
$settingsObj = $documentedObj.Assignments | Where-Object { $_.Settings -ne $null } | Select-Object -First 1
|
||||
if ($settingsObj) {
|
||||
foreach ($objProp in $settingsObj.Settings.Keys) {
|
||||
if ($objProp -in $properties) { continue }
|
||||
if ($objProp -in @("Category","RawIntent")) { continue }
|
||||
$settingProps += "Settings.$objProp"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$hasFilter = $false
|
||||
foreach ($a in $documentedObj.Assignments) {
|
||||
if ($a.PSObject.Properties.Name -contains "FilterMode") { $hasFilter = $true; break }
|
||||
}
|
||||
$properties = @("Group")
|
||||
if ($hasFilter) { $properties += @("Filter","FilterMode") }
|
||||
}
|
||||
|
||||
Add-DocTableItems $PolicyObject $typeTitle $documentedObj.Assignments $properties -LngId "TableHeaders.assignments" -AddCategories
|
||||
|
||||
if ($null -ne $settingProps) {
|
||||
# Adds additional values to the assignments table for Apps assignments
|
||||
Set-DocTableSettingsItems $documentedObj.Assignments $settingProps 3
|
||||
}
|
||||
}
|
||||
|
||||
if ($attachJson) {
|
||||
# Embed the full raw object JSON as an OLE object (old feature gated on
|
||||
# chkWordAttachJsonFile). The full object is already in hand, so write it
|
||||
# to a temp file directly rather than depending on an external exporter.
|
||||
try {
|
||||
# The policy's real name, not the heading: the heading may be a
|
||||
# DocumentName override, and this is the attached object's label.
|
||||
$safeName = ([string]$PolicyObject.Name)
|
||||
foreach ($ch in [IO.Path]::GetInvalidFileNameChars()) { $safeName = $safeName.Replace($ch, '_') }
|
||||
if ([string]::IsNullOrEmpty($safeName)) { $safeName = 'object' }
|
||||
$fi = [IO.FileInfo](Join-Path ([IO.Path]::GetTempPath()) "$safeName.json")
|
||||
($PolicyObject.JsonObject | ConvertTo-Json -Depth 50) | Out-File -LiteralPath $fi.FullName -Encoding UTF8
|
||||
$fi.Refresh()
|
||||
if ($fi.Exists) {
|
||||
$script:doc.Application.Selection.InlineShapes.AddOLEObject("", $fi.FullName, $false, $true, "$($env:WinDir)\System32\Notepad.exe", 0, $fi.Name)
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
try { $fi.Delete() } catch { }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to attach JSON for $objName" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
function Set-DocTableSettingsItems {
|
||||
param($items, $properties, [int]$firstColumn)
|
||||
|
||||
$secondColumn = $firstColumn + 1
|
||||
|
||||
$script:docTable.Cell(1, $firstColumn).Range.Text = (Invoke-DocTranslateColumnHeader "Settings")
|
||||
$script:docTable.Cell(1, $secondColumn).Range.Text = ""
|
||||
|
||||
$row = 2
|
||||
foreach ($itemObj in $items) {
|
||||
while ($script:docTable.Cell($row, 1).Next.RowIndex -gt $row) {
|
||||
# Category / Sub-category row — skip
|
||||
$row++
|
||||
}
|
||||
$script:docTable.Cell($row, $firstColumn).Range.Text = ""
|
||||
$script:docTable.Cell($row, $secondColumn).Range.Text = ""
|
||||
$script:docTable.Cell($row, $firstColumn).Split($properties.Count, 1)
|
||||
$script:docTable.Cell($row, $secondColumn).Split($properties.Count, 1)
|
||||
|
||||
$cellRow = $row
|
||||
foreach ($settingProp in $properties) {
|
||||
if ([string]::IsNullOrEmpty($settingProp)) { continue }
|
||||
|
||||
$script:docTable.Cell($cellRow, $firstColumn).Range.Text = (Invoke-DocTranslateColumnHeader ($settingProp.Split('.')[-1]))
|
||||
|
||||
$propArr = $settingProp.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
|
||||
$script:docTable.Cell($cellRow, $secondColumn).Range.Text = "$($tmpObj.$propName)"
|
||||
$cellRow++
|
||||
}
|
||||
$row = $row + $properties.Count
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WordProcessAllObjects {
|
||||
param($allObjectTypeObjects)
|
||||
# ScopeTags consolidated table is deferred — depends on Get-TableObjects-style
|
||||
# cross-object accumulation that hasn't been ported yet. Phase 2 re-enables.
|
||||
}
|
||||
|
||||
function Add-DocTableItems {
|
||||
param(
|
||||
$PolicyObject,
|
||||
[string]$TypeTitle,
|
||||
$Items,
|
||||
[string[]]$Properties,
|
||||
[string]$LngId,
|
||||
[switch]$AddCategories,
|
||||
[switch]$AddSubcategories,
|
||||
$CaptionOverride,
|
||||
[switch]$ForceFullValue
|
||||
)
|
||||
|
||||
if (($Items | Measure-Object).Count -eq 0) { return }
|
||||
|
||||
$tblHeaderStyle = Get-DocumentationOutputOption word "WordTableHeaderStyle" ""
|
||||
$tblCategoryStyle = Get-DocumentationOutputOption word "WordCategoryHeaderStyle" ""
|
||||
$tblSubCategoryStyle = Get-DocumentationOutputOption word "WordSubCategoryHeaderStyle" ""
|
||||
$tblTextStyle = Get-DocumentationOutputOption word "WordTableTextStyle" ""
|
||||
$tblStyle = Get-DocumentationOutputOption word "WordTableStyle" "Grid table 4 - Accent 3"
|
||||
$captionPos = Get-DocumentationOutputOption word "WordTableCaptionPosition" "below"
|
||||
$docLevel = Get-DocumentationOutputOption word "WordDocumentationLevel" "full"
|
||||
$limitAttach = (Get-DocumentationOutputOption word "WordDocumentationLimitAttach" $false) -eq $true
|
||||
|
||||
$range = $script:doc.Application.Selection.Range
|
||||
|
||||
# Pre-pass: count category / sub-category rows so the table can be allocated at its final size.
|
||||
# Boundary logic MUST stay in sync with the main fill loop below.
|
||||
$extraRows = 0
|
||||
$preCat = ""
|
||||
$preSubCat = ""
|
||||
foreach ($itemObj in $Items) {
|
||||
if ($itemObj.Category -and $preCat -ne $itemObj.Category -and $AddCategories) {
|
||||
$extraRows++
|
||||
$preCat = $itemObj.Category
|
||||
$preSubCat = ""
|
||||
}
|
||||
if ($itemObj.SubCategory -and $preSubCat -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
$extraRows++
|
||||
$preSubCat = $itemObj.SubCategory
|
||||
}
|
||||
}
|
||||
|
||||
$totalRows = @($Items).Count + 1 + $extraRows
|
||||
|
||||
# Create with wdAutoFitFixed during fill — wdAutoFitWindow recalculates column widths after every cell write.
|
||||
# We re-enable wdAutoFitWindow once after the rows are populated.
|
||||
$script:docTable = $script:doc.Tables.Add($range, $totalRows, $Properties.Count, [Microsoft.Office.Interop.Word.WdDefaultTableBehavior]::wdWord9TableBehavior, [Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitFixed)
|
||||
$script:docTable.ApplyStyleHeadingRows = $true
|
||||
Set-DocObjectStyle $script:docTable $tblStyle | Out-Null
|
||||
|
||||
if ($CaptionOverride) {
|
||||
$caption = $CaptionOverride
|
||||
}
|
||||
elseif ($LngId -and $PolicyObject) {
|
||||
$caption = "$((Get-LanguageString $LngId)) - $(Get-DocCaptionName $PolicyObject)"
|
||||
}
|
||||
elseif ($PolicyObject) {
|
||||
$caption = "$(Get-DocCaptionName $PolicyObject) ($TypeTitle)"
|
||||
}
|
||||
else {
|
||||
$caption = $TypeTitle
|
||||
}
|
||||
|
||||
$i = 1
|
||||
foreach ($prop in $Properties) {
|
||||
if ([string]::IsNullOrEmpty($prop)) { continue }
|
||||
$script:docTable.Cell(1, $i).Range.Text = (Invoke-DocTranslateColumnHeader ($prop.Split('.')[-1]))
|
||||
$i++
|
||||
}
|
||||
|
||||
if (-not (Set-DocObjectStyle $script:docTable.Rows(1).Range $tblHeaderStyle)) {
|
||||
$script:docTable.Rows(1).Range.Font.Size += 2
|
||||
$script:docTable.Rows(1).Range.Font.Bold = $true
|
||||
}
|
||||
|
||||
$curCategory = ""
|
||||
$curSubCategory = ""
|
||||
|
||||
$row = 2
|
||||
foreach ($itemObj in $Items) {
|
||||
try {
|
||||
if ($itemObj.Category -and $curCategory -ne $itemObj.Category -and $AddCategories) {
|
||||
try { $script:docTable.Rows.Item($row).Cells.Merge() } catch { }
|
||||
$script:docTable.Cell($row, 1).Range.Text = $itemObj.Category
|
||||
|
||||
if (-not (Set-DocObjectStyle $script:docTable.Rows($row).Range $tblCategoryStyle)) {
|
||||
$script:docTable.Rows($row).Range.Font.Size += 2
|
||||
$script:docTable.Rows($row).Range.Font.Italic = $true
|
||||
}
|
||||
|
||||
$row++
|
||||
$curCategory = $itemObj.Category
|
||||
$curSubCategory = ""
|
||||
}
|
||||
|
||||
if ($itemObj.SubCategory -and $curSubCategory -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
try { $script:docTable.Rows.Item($row).Cells.Merge() } catch { }
|
||||
$script:docTable.Cell($row, 1).Range.Text = $itemObj.SubCategory
|
||||
|
||||
if (-not (Set-DocObjectStyle $script:docTable.Rows($row).Range $tblSubCategoryStyle)) {
|
||||
$script:docTable.Rows($row).Range.Font.Italic = $true
|
||||
}
|
||||
|
||||
$row++
|
||||
$curSubCategory = $itemObj.SubCategory
|
||||
}
|
||||
|
||||
$i = 1
|
||||
foreach ($prop in $Properties) {
|
||||
try {
|
||||
$propArr = $prop.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
$propValue = "$($tmpObj.$propName)"
|
||||
$propValueFull = $null
|
||||
|
||||
if (-not $ForceFullValue -and $docLevel -eq "limited" -and $propValue.Length -gt $script:limitMaxValue) {
|
||||
$propValueFull = $propValue
|
||||
if ($script:truncateValueLength -gt 0) {
|
||||
$propValue = $propValue.Substring(0, $script:truncateValueLength) + "..."
|
||||
if ($limitAttach) { $propValue = "`r`n" + $propValue }
|
||||
}
|
||||
else {
|
||||
$propValue = $null
|
||||
}
|
||||
}
|
||||
|
||||
$levelExtra = ""
|
||||
if ($i -eq 1 -and $itemObj.Level) {
|
||||
try {
|
||||
$level = [int]$itemObj.Level
|
||||
if ($level -lt 0) { $level = 0 }
|
||||
if ($level -gt 0) {
|
||||
$levelExtra = [string]::new(" ", ($level * 2))
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if ($null -ne $propValue) {
|
||||
$script:docTable.Cell($row, $i).Range.Text = "$levelExtra$propValue"
|
||||
}
|
||||
|
||||
if ($propValueFull -and $limitAttach) {
|
||||
$tmpName = "$($PolicyObject.Name)-$propName"
|
||||
$tmpFile = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "$tmpName.txt")
|
||||
$tmpFile = Remove-InvalidFileNameChars $tmpFile
|
||||
$propValueFull | Out-File -LiteralPath $tmpFile -Force
|
||||
$fi = [IO.FileInfo]$tmpFile
|
||||
[void]$script:docTable.Cell($row, $i).Range.InlineShapes.AddOLEObject("", $fi.FullName, $false, $true, "$($env:WinDir)\System32\Notepad.exe", 0, "Full value")
|
||||
try { $fi.Delete() } catch { }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add property value for $prop" $_.Exception
|
||||
}
|
||||
$i++
|
||||
}
|
||||
|
||||
Set-DocObjectStyle $script:docTable.Rows($row).Range $tblTextStyle | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to process property" 2
|
||||
}
|
||||
|
||||
$row++
|
||||
}
|
||||
|
||||
try { $script:docTable.AutoFitBehavior([Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitWindow) } catch { }
|
||||
|
||||
# -2 = Table caption, 1 = Below / 0 = Above
|
||||
$capPos = if ($captionPos -eq "above") { 0 } else { 1 }
|
||||
$script:docTable.Application.Selection.InsertCaption(-2, ". $caption", $null, $capPos)
|
||||
|
||||
Invoke-DocGoToEnd
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
}
|
||||
|
||||
function Add-DocTableScript {
|
||||
param([string]$Caption, [string]$Header, [string]$ScriptText)
|
||||
|
||||
if (-not $ScriptText) { return }
|
||||
|
||||
$primary = Get-DocumentationOutputOption word "WordScriptTableStyle" ""
|
||||
if (-not $primary) {
|
||||
$primary = Get-DocumentationOutputOption word "WordTableStyle" "Grid table 4 - Accent 3"
|
||||
}
|
||||
$scriptStyle = Get-DocumentationOutputOption word "WordScriptStyle" ""
|
||||
|
||||
$range = $script:doc.Application.Selection.Range
|
||||
$scriptTable = $script:doc.Tables.Add($range, 2, 1, [Microsoft.Office.Interop.Word.WdDefaultTableBehavior]::wdWord9TableBehavior, [Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitFixed)
|
||||
$scriptTable.ApplyStyleHeadingRows = $true
|
||||
Set-DocObjectStyle $scriptTable $primary | Out-Null
|
||||
|
||||
if ($Header) {
|
||||
$scriptTable.Cell(1, 1).Range.Text = $Header
|
||||
}
|
||||
|
||||
$scriptTable.Cell(2, 1).Range.Font.Bold = $false
|
||||
$scriptTable.Cell(2, 1).Range.Text = $ScriptText
|
||||
if ($scriptStyle) {
|
||||
Set-DocObjectStyle $scriptTable.Rows(2).Range $scriptStyle | Out-Null
|
||||
}
|
||||
else {
|
||||
$tmp = $script:wordStyles["HTML Code"]
|
||||
if ($tmp) {
|
||||
$scriptTable.Cell(2, 1).Range.Font = $tmp.Style.Font
|
||||
}
|
||||
$scriptTable.Cell(2, 1).Range.Font.Bold = $false
|
||||
}
|
||||
$scriptTable.Cell(2, 1).Range.NoProofing = $true
|
||||
|
||||
try { $scriptTable.AutoFitBehavior([Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitWindow) } catch { }
|
||||
$scriptTable.Application.Selection.InsertCaption(-2, ". $Caption", $null, 1)
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
}
|
||||
|
||||
function Get-DocStyle {
|
||||
param([string]$StyleName)
|
||||
|
||||
$tmpStyle = $null
|
||||
if ($StyleName -and $script:wordStyles.ContainsKey($StyleName)) {
|
||||
$tmpStyle = $script:wordStyles[$StyleName].Style
|
||||
}
|
||||
if (-not $tmpStyle) { Write-Log "Style $StyleName not found" }
|
||||
$tmpStyle
|
||||
}
|
||||
|
||||
function Add-DocText {
|
||||
param([string]$Text, [string]$Style, [switch]$SkipAddParagraph)
|
||||
|
||||
Set-DocObjectStyle $script:doc.Application.Selection $Style | Out-Null
|
||||
$script:doc.Application.Selection.TypeText($Text)
|
||||
if (-not $SkipAddParagraph) {
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-DocGoToEnd {
|
||||
$script:doc.Application.Selection.GoTo([Microsoft.Office.Interop.Word.WdGoToItem]::wdGoToBookmark, $null, $null, '\EndOfDoc') | Out-Null
|
||||
}
|
||||
|
||||
function Set-WordDocBuiltInProperty {
|
||||
param([string]$PropertyName, $Value)
|
||||
|
||||
try {
|
||||
$script:doc.BuiltInDocumentProperties([Microsoft.Office.Interop.Word.WdBuiltInProperty]$PropertyName) = $Value
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to set built in property $PropertyName to $Value" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
function Set-DocObjectStyle {
|
||||
param($DocObj, [string]$ObjStyle)
|
||||
|
||||
$styleSet = $false
|
||||
if ($DocObj -and $ObjStyle) {
|
||||
try {
|
||||
if ($script:builtinStyles.ContainsKey($ObjStyle)) {
|
||||
$DocObj.style = [Microsoft.Office.Interop.Word.wdBuiltinStyle]$ObjStyle
|
||||
}
|
||||
else {
|
||||
$DocObj.style = $ObjStyle
|
||||
}
|
||||
$styleSet = $true
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to set style: $ObjStyle" 3
|
||||
}
|
||||
}
|
||||
$styleSet
|
||||
}
|
||||
|
||||
function Add-DocObjectScripts {
|
||||
param($documentedObj)
|
||||
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent -or -not $scriptItem.Caption) { continue }
|
||||
Add-DocTableScript $scriptItem.Caption $scriptItem.Header $scriptItem.ScriptContent
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeWordOutput
|
||||
Reference in New Issue
Block a user