@simone.trenta
Here is a PowerShell script generated by Claude and tested by me as it’s working.
<#
.SYNOPSIS
Generates technical documentation (.xlsx) for a UiPath project by scanning every
.xaml workflow in the project folder and all subfolders.
.DESCRIPTION
For each workflow found, extracts:
- Packages used (from project.json), with versions
- Activities used, with display names and property values
- Invoked workflows (InvokeWorkflowFile), with their argument bindings
Produces a single .xlsx with 4 sheets: Workflows, Packages, Activities, Invoked Workflows.
.PARAMETER ProjectDir
Root folder of the UiPath project. Defaults to the folder this script lives in,
so the script can just be dropped into a project folder and run with no arguments.
.PARAMETER OutputPath
Path of the .xlsx to generate. Defaults to "<ProjectDir>\Documentation\ProcessDocumentation.xlsx".
.PARAMETER ExcludeDirs
Top-level or nested folder names to skip entirely (build/metadata folders, not process folders).
.EXAMPLE
.\Generate-ProcessDocumentation.ps1
Run from inside the project folder with no arguments.
.EXAMPLE
.\Generate-ProcessDocumentation.ps1 -ProjectDir "C:\Projects\MyProcess" -OutputPath "C:\Reports\MyProcess.xlsx"
#>
param(
[string]$ProjectDir = $PSScriptRoot,
[string]$OutputPath = $(Join-Path $PSScriptRoot 'Documentation\ProcessDocumentation.xlsx'),
[string[]]$ExcludeDirs = @('.local', '.settings', '.git', '.vs', 'bin', 'obj', 'node_modules', '.tmh')
)
$ErrorActionPreference = 'Stop'
$xNs = 'http://schemas.microsoft.com/winfx/2006/xaml'
$sap2010Ns = 'http://schemas.microsoft.com/netfx/2010/xaml/activities/presentation'
if (-not (Test-Path $ProjectDir)) { throw "ProjectDir not found: $ProjectDir" }
$ProjectDir = (Resolve-Path $ProjectDir).Path.TrimEnd('\')
Write-Host "Scanning project: $ProjectDir" -ForegroundColor Cyan
# ---------- 1. Packages (from project.json) ----------
$projectJsonPath = Join-Path $ProjectDir 'project.json'
$packages = @()
$mainEntry = 'Main.xaml'
if (Test-Path $projectJsonPath) {
$projectJson = Get-Content $projectJsonPath -Raw | ConvertFrom-Json
if ($projectJson.main) { $mainEntry = $projectJson.main }
if ($projectJson.dependencies) {
$packages = foreach ($dep in $projectJson.dependencies.PSObject.Properties) {
[PSCustomObject]@{ Package = $dep.Name; Version = ($dep.Value -replace '[\[\]]', '') }
}
}
} else {
Write-Warning "project.json not found under $ProjectDir - Packages sheet will be empty."
}
# ---------- 2. Discover every .xaml recursively, excluding build/metadata folders ----------
$allXaml = Get-ChildItem -Path $ProjectDir -Filter '*.xaml' -Recurse -File
$xamlFiles = $allXaml | Where-Object {
$rel = $_.FullName.Substring($ProjectDir.Length + 1)
$segments = $rel -split '[\\/]'
-not ($segments | Where-Object { $ExcludeDirs -contains $_ })
}
function Get-Category([string]$relPath, [string]$mainEntry) {
if ($relPath -eq $mainEntry -or (Split-Path $relPath -Leaf) -eq (Split-Path $mainEntry -Leaf) -and (Split-Path $relPath -Parent) -eq '') {
return 'Main'
}
$parent = Split-Path $relPath -Parent
if ([string]::IsNullOrEmpty($parent)) { return 'Root' }
return ($parent -split '[\\/]')[0]
}
$fileList = foreach ($f in $xamlFiles) {
$rel = $f.FullName.Substring($ProjectDir.Length + 1)
[PSCustomObject]@{
Item = $f
RelPath = $rel
Category = Get-Category $rel $mainEntry
}
}
# Main first, then alphabetical by Category, then by file path
$orderedFiles = @($fileList | Where-Object { $_.Category -eq 'Main' }) +
@($fileList | Where-Object { $_.Category -ne 'Main' } | Sort-Object Category, RelPath)
Write-Host "Found $($orderedFiles.Count) workflow file(s) after exclusions." -ForegroundColor Cyan
# ---------- helpers for activity/property extraction ----------
$excludeElementLocalNames = @(
'Dictionary', 'List', 'Collection', 'ActivityAction', 'ActivityFunc',
'DelegateInArgument', 'InArgument', 'OutArgument', 'InOutArgument',
'Variable', 'Members', 'Property', 'Null', 'Reference', 'Boolean', 'String',
'Int32', 'Double', 'Point', 'Size', 'PointCollection', 'Settings',
'AssemblyReference'
)
$excludeAttrPrefixes = @('sap', 'sap2010', 'mc', 'xmlns')
function Truncate([string]$s, [int]$max = 300) {
if ($null -eq $s) { return '' }
$s = ($s -replace '\s+', ' ').Trim()
if ($s.Length -gt $max) { return $s.Substring(0, $max) + '...' }
return $s
}
function Get-PropertyElementSimpleValue($node) {
$localName = $node.LocalName
$propName = $localName.Substring($localName.LastIndexOf('.') + 1)
if ($propName -in @('Arguments', 'Analytics', 'Output', 'ViewState')) { return $null }
if ($node.Prefix -in @('sap', 'sap2010')) { return $null }
$elemChildren = @($node.ChildNodes | Where-Object { $_.NodeType -eq [System.Xml.XmlNodeType]::Element })
if ($elemChildren.Count -eq 0) {
$t = Truncate $node.InnerText
if ([string]::IsNullOrWhiteSpace($t)) { return $null }
return "$propName=$t"
}
$wrapperTypes = @('InArgument', 'OutArgument', 'InOutArgument')
$nonWrapperCount = @($elemChildren | Where-Object { $_.LocalName -notin $wrapperTypes }).Count
if ($nonWrapperCount -eq 0) {
$vals = $elemChildren | ForEach-Object { Truncate $_.InnerText } | Where-Object { $_ -ne '' }
if ($vals.Count -eq 0) { return $null }
return "$propName=" + ($vals -join '; ')
}
return $null
}
$activityRows = New-Object System.Collections.Generic.List[object]
$invokedRows = New-Object System.Collections.Generic.List[object]
function Walk-Node($node, $workflowName) {
foreach ($child in $node.ChildNodes) {
if ($child.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
$localName = $child.LocalName
$prefix = $child.Prefix
$isPropertyElement = $localName -like '*.*'
$isExcludedName = $excludeElementLocalNames -contains $localName
if (-not $isPropertyElement -and -not $isExcludedName) {
$displayName = $child.GetAttribute('DisplayName')
$propParts = New-Object System.Collections.Generic.List[string]
foreach ($attr in $child.Attributes) {
$aPrefix = $attr.Prefix
if ($aPrefix -in $excludeAttrPrefixes) { continue }
if ($attr.LocalName -eq 'xmlns' -or $aPrefix -eq 'xmlns') { continue }
if ($attr.LocalName -eq 'DisplayName') { continue }
if ($attr.LocalName -in @('Name', 'Key', 'Class')) { continue }
if ($aPrefix -eq 'x' -and $attr.LocalName -ne 'TypeArguments') { continue }
$val = Truncate $attr.Value
if ($val -eq '') { continue }
$propParts.Add("$($attr.LocalName)=$val")
}
foreach ($pe in $child.ChildNodes) {
if ($pe.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
if ($pe.LocalName -notlike '*.*') { continue }
$simple = Get-PropertyElementSimpleValue $pe
if ($simple) { $propParts.Add($simple) }
}
$activityType = if ($prefix) { "$prefix`:$localName" } else { $localName }
$activityRows.Add([PSCustomObject]@{
Workflow = $workflowName
ActivityType = $activityType
DisplayName = $displayName
Properties = ($propParts -join ' | ')
})
if ($localName -eq 'InvokeWorkflowFile') {
$wfFile = $child.GetAttribute('WorkflowFileName')
$argParts = New-Object System.Collections.Generic.List[string]
foreach ($sub in $child.ChildNodes) {
if ($sub.NodeType -eq [System.Xml.XmlNodeType]::Element -and $sub.LocalName -eq 'InvokeWorkflowFile.Arguments') {
foreach ($argNode in $sub.ChildNodes) {
if ($argNode.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
if ($argNode.LocalName -eq 'Dictionary') { continue }
$dir = $argNode.LocalName
$key = $argNode.GetAttribute('Key', $xNs)
$val = Truncate $argNode.InnerText 120
$argParts.Add("$dir $key = $val")
}
}
}
$invokedRows.Add([PSCustomObject]@{
Workflow = $workflowName
DisplayName = $displayName
InvokedWorkflow = $wfFile
Arguments = ($argParts -join ' | ')
})
}
}
Walk-Node $child $workflowName
}
}
# ---------- 3. Parse every workflow ----------
$workflowSummary = New-Object System.Collections.Generic.List[object]
foreach ($f in $orderedFiles) {
$fullPath = $f.Item.FullName
try {
[xml]$xml = Get-Content $fullPath -Raw
} catch {
Write-Warning "Skipping unparsable XAML: $($f.RelPath) - $($_.Exception.Message)"
continue
}
$before = $activityRows.Count
$beforeInv = $invokedRows.Count
Walk-Node $xml.DocumentElement $f.RelPath
$rootAnnotation = ''
$rootEl = $xml.DocumentElement
$ann = $rootEl.GetAttributeNode('Annotation.AnnotationText', $sap2010Ns)
if (-not $ann) {
foreach ($c in $rootEl.ChildNodes) {
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.LocalName -notlike '*.*') {
$a2 = $c.GetAttributeNode('Annotation.AnnotationText', $sap2010Ns)
if ($a2) { $ann = $a2; break }
}
}
}
if ($ann) { $rootAnnotation = Truncate $ann.Value 400 }
$workflowSummary.Add([PSCustomObject]@{
File = $f.RelPath
Category = $f.Category
Description = $rootAnnotation
ActivityCount = $activityRows.Count - $before
InvokedWfCount = $invokedRows.Count - $beforeInv
})
}
Write-Host "Packages: $($packages.Count) | Workflows: $($workflowSummary.Count) | Activities: $($activityRows.Count) | InvokedWorkflows: $($invokedRows.Count)" -ForegroundColor Cyan
# ---------- 4. Write the .xlsx ----------
$outDir = Split-Path $OutputPath -Parent
if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
if (-not (Get-Module -ListAvailable -Name ImportExcel)) {
Write-Host "ImportExcel module not found - attempting install for current user..." -ForegroundColor Yellow
try {
Install-Module ImportExcel -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
} catch {
Write-Warning "Could not install ImportExcel ($($_.Exception.Message)). Falling back to CSV export next to the requested output path."
}
}
if (Get-Module -ListAvailable -Name ImportExcel) {
Import-Module ImportExcel
# If the target file exists but is locked (e.g. still open in Excel), fall back to a
# timestamped filename instead of failing outright.
if (Test-Path $OutputPath) {
try {
Remove-Item $OutputPath -Force -ErrorAction Stop
} catch {
$dir = Split-Path $OutputPath -Parent
$base = [System.IO.Path]::GetFileNameWithoutExtension($OutputPath)
$ext = [System.IO.Path]::GetExtension($OutputPath)
$stamped = Join-Path $dir "$base`_$(Get-Date -Format 'yyyyMMdd_HHmmss')$ext"
Write-Warning "Could not overwrite '$OutputPath' (likely open in Excel). Writing to '$stamped' instead."
$OutputPath = $stamped
}
}
$workflowSummary | Export-Excel -Path $OutputPath -WorksheetName 'Workflows' -AutoSize -FreezeTopRow -BoldTopRow -AutoFilter
$packages | Export-Excel -Path $OutputPath -WorksheetName 'Packages' -AutoSize -FreezeTopRow -BoldTopRow -AutoFilter
$activityRows | Export-Excel -Path $OutputPath -WorksheetName 'Activities' -AutoSize -FreezeTopRow -BoldTopRow -AutoFilter
$invokedRows | Export-Excel -Path $OutputPath -WorksheetName 'Invoked Workflows' -AutoSize -FreezeTopRow -BoldTopRow -AutoFilter
Write-Host "Documentation written to: $OutputPath" -ForegroundColor Green
} else {
$base = [System.IO.Path]::ChangeExtension($OutputPath, $null).TrimEnd('.')
$workflowSummary | Export-Csv "$base.Workflows.csv" -NoTypeInformation -Encoding UTF8
$packages | Export-Csv "$base.Packages.csv" -NoTypeInformation -Encoding UTF8
$activityRows | Export-Csv "$base.Activities.csv" -NoTypeInformation -Encoding UTF8
$invokedRows | Export-Csv "$base.InvokedWorkflows.csv" -NoTypeInformation -Encoding UTF8
Write-Host "ImportExcel unavailable - wrote 4 CSV files next to: $base.*.csv" -ForegroundColor Yellow
}
This is the output: