- teams: harden Classic Teams removal, protect New Teams, and clean HKU run keys - dotnet: add bootstrapper EXE uninstall support and remove orphaned 3.1.* folders - teams: add MSIX package targeting and version parsing for vulnerable New Teams builds - m365: improve Graph SDK export speed, reuse sessions, and add null-safe handling
296 lines
9.4 KiB
PowerShell
Executable File
296 lines
9.4 KiB
PowerShell
Executable File
<#
|
|
.SYNOPSIS
|
|
Generates interactive HTML and plain-text directory tree reports.
|
|
|
|
.DESCRIPTION
|
|
Recursively scans a target directory and produces two report files:
|
|
1. A plain-text ASCII directory tree file (.txt).
|
|
2. An interactive HTML tree file (.html) with search filtering and collapse/expand controls.
|
|
|
|
.PARAMETER RootPath
|
|
The target root folder path to scan.
|
|
|
|
.PARAMETER OutputDirectory
|
|
The destination directory where reports will be saved. Defaults to the current working directory.
|
|
|
|
.PARAMETER ReportNamePrefix
|
|
Optional custom prefix for generated filenames. Defaults to the sanitized root folder name.
|
|
|
|
.PARAMETER OpenInBrowser
|
|
Switch parameter to automatically open the generated HTML report upon completion.
|
|
|
|
.EXAMPLE
|
|
.\Export-DirectoryTree.ps1 -RootPath "E:\Sync Method\Corporate\Documents" -OutputDirectory "C:\Reports" -OpenInBrowser
|
|
|
|
.NOTES
|
|
Created: 2025-03-02
|
|
Modified: 2026-10-04
|
|
#>
|
|
|
|
[CmdletBinding()]
|
|
param (
|
|
[Parameter(Mandatory = $true, Position = 0, ValueFromPipeline =$true)]
|
|
[ValidateNotNullOrEmpty()]
|
|
[string]$RootPath,
|
|
|
|
[Parameter(Mandatory = $false, Position = 1)] [string]$OutputDirectory = (Get-Location).Path,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[string]$ReportNamePrefix,
|
|
|
|
[switch]$OpenInBrowser
|
|
)
|
|
|
|
# 1. Validate & Resolve Paths
|
|
if (-not (Test-Path -LiteralPath $RootPath -PathType Container)) {
|
|
Write-Error "Target root directory not found or unreadable: '$RootPath'"
|
|
return
|
|
}
|
|
|
|
$ResolvedRoot = (Get-Item -LiteralPath$RootPath).FullName
|
|
$RootLeafName = Split-Path -Path$ResolvedRoot -Leaf
|
|
|
|
# Auto-generate report prefix if not explicitly provided
|
|
if ([string]::IsNullOrWhiteSpace($ReportNamePrefix)) {
|
|
$ReportNamePrefix =$RootLeafName -replace '[\/\\:\*\?"<>\|]', '-'
|
|
}
|
|
|
|
# Ensure output directory exists
|
|
if (-not (Test-Path -LiteralPath $OutputDirectory)) {
|
|
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
|
|
}
|
|
|
|
$TextFile = Join-Path -Path $OutputDirectory -ChildPath "$ReportNamePrefix-tree.txt"
|
|
$HtmlFile = Join-Path -Path $OutputDirectory -ChildPath "$ReportNamePrefix-tree.html"
|
|
|
|
# 2. State Tracking Setup
|
|
$script:textLines = [System.Collections.Generic.List[string]]::new()
|
|
$script:html = [System.Text.StringBuilder]::new()$script:scanErrors = 0
|
|
$script:totalFiles = 0$script:totalFolders = 0
|
|
|
|
function ConvertTo-HtmlEncoded {
|
|
param([string]$Text)
|
|
return [System.Net.WebUtility]::HtmlEncode($Text)
|
|
}
|
|
|
|
function Add-TreeItems {
|
|
param(
|
|
[string]$Path,
|
|
[string]$Prefix
|
|
)
|
|
|
|
try {
|
|
$items = @(
|
|
Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop |
|
|
Sort-Object @{ Expression = { if ($_.PSIsContainer) { 0 } else { 1 } } }, Name
|
|
)
|
|
}
|
|
catch {
|
|
$script:scanErrors++
|
|
[void]$script:textLines.Add("$Prefix[Unable to read: $($_.Exception.Message)]")
|
|
[void]$script:html.AppendLine(
|
|
"<li class='error'>Unable to read: $(ConvertTo-HtmlEncoded$_.Exception.Message)</li>"
|
|
)
|
|
return
|
|
}
|
|
|
|
for ($index = 0; $index -lt $items.Count; $index++) {
|
|
$item =$items[$index]$isLast = $index -eq ($items.Count - 1)
|
|
$connector = if ($isLast) { '\-- ' } else { '|-- ' }
|
|
$nextPrefix =$Prefix + $(if ($isLast) { ' ' } else { '| ' })
|
|
|
|
$nameHtml = ConvertTo-HtmlEncoded$item.Name
|
|
$pathHtml = ConvertTo-HtmlEncoded$item.FullName
|
|
$isLink = -not [string]::IsNullOrWhiteSpace([string]$item.LinkType)
|
|
|
|
if ($item.PSIsContainer -and -not $isLink) {$script:totalFolders++
|
|
[void]$script:textLines.Add("$Prefix$connector$($item.Name)")
|
|
[void]$script:html.AppendLine(
|
|
"<li><details data-path='$pathHtml'><summary>📁 $nameHtml</summary><ul>"
|
|
)
|
|
|
|
# Recurse subdirectories
|
|
Add-TreeItems -Path $item.FullName -Prefix$nextPrefix
|
|
|
|
[void]$script:html.AppendLine('</ul></details></li>')
|
|
}
|
|
elseif ($item.PSIsContainer -and $isLink) {$script:totalFolders++
|
|
$target = @($item.Target) -join ', '
|
|
[void]$script:textLines.Add("$Prefix$connector$($item.Name) [Link → $target]")
|
|
[void]$script:html.AppendLine(
|
|
"<li class='link' data-path='$pathHtml'>🔗 $nameHtml <small>→ $(ConvertTo-HtmlEncoded$target)</small></li>"
|
|
)
|
|
}
|
|
else {
|
|
$script:totalFiles++
|
|
[void]$script:textLines.Add("$Prefix$connector$($item.Name)")
|
|
[void]$script:html.AppendLine(
|
|
"<li class='file' data-path='$pathHtml'>📄 $nameHtml</li>"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
# 3. Document Building
|
|
Write-Host "Scanning directory structure: $ResolvedRoot" -ForegroundColor Cyan
|
|
|
|
$rootNameHtml = ConvertTo-HtmlEncoded $RootLeafName$rootPathHtml = ConvertTo-HtmlEncoded $ResolvedRoot$generated = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
|
|
|
[void]$script:textLines.Add($ResolvedRoot)
|
|
|
|
[void]$script:html.AppendLine(@"
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Directory Tree - $rootNameHtml</title>
|
|
<style>
|
|
:root {
|
|
--bg-color: #ffffff;
|
|
--text-color: #1f2328;
|
|
--border-color: #d0d7de;
|
|
--link-color: #8250df;
|
|
--error-color: #cf222e;
|
|
--subtext-color: #57606a;
|
|
}
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
margin: 24px;
|
|
color: var(--text-color);
|
|
background: var(--bg-color);
|
|
}
|
|
h1 { margin-bottom: 6px; font-size: 1.5rem; }
|
|
.meta { color: var(--subtext-color); margin-bottom: 18px; font-size: 0.9rem; line-height: 1.4; }
|
|
.controls {
|
|
position: sticky;
|
|
top: 0;
|
|
background: var(--bg-color);
|
|
padding: 10px 0;
|
|
border-bottom: 1px solid var(--border-color);
|
|
z-index: 10;
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
input[type="search"] {
|
|
width: min(500px, 60vw);
|
|
padding: 6px 10px;
|
|
font-size: 14px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
}
|
|
button {
|
|
padding: 6px 12px;
|
|
font-size: 14px;
|
|
background-color: #f6f8fa;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
}
|
|
button:hover { background-color: #f3f4f6; }
|
|
ul {
|
|
list-style: none;
|
|
margin: 4px 0 4px 16px;
|
|
padding-left: 12px;
|
|
border-left: 1px solid #d8dee4;
|
|
}
|
|
.tree { border-left: 0; margin-left: 0; padding-left: 0; }
|
|
summary { cursor: pointer; padding: 3px; font-weight: 500; }
|
|
summary:hover { background-color: #f6f8fa; border-radius: 4px; }
|
|
.file, .link { padding: 3px; }
|
|
.link { color: var(--link-color); }
|
|
.error { color: var(--error-color); }
|
|
small { color: var(--subtext-color); }
|
|
.hidden { display: none !important; }
|
|
code { background: #f6f8fa; padding: 2px 6px; border-radius: 4px; font-size: 0.85em; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Directory Tree Report</h1>
|
|
<div class="meta">
|
|
<strong>Root:</strong> <code>$rootPathHtml</code><br>
|
|
<strong>Generated:</strong> $generated
|
|
</div>
|
|
<div class="controls">
|
|
<input id="search" type="search" placeholder="Search files and folders...">
|
|
<button type="button" onclick="setAll(true)">Expand All</button>
|
|
<button type="button" onclick="setAll(false)">Collapse All</button>
|
|
</div>
|
|
<ul class="tree">
|
|
<li>
|
|
<details open data-path="$rootPathHtml">
|
|
<summary>📁 $rootNameHtml</summary>
|
|
<ul>
|
|
"@)
|
|
|
|
# Run recursive scan
|
|
Add-TreeItems -Path $ResolvedRoot -Prefix ''
|
|
|
|
[void]$script:html.AppendLine(@"
|
|
</ul>
|
|
</details>
|
|
</li>
|
|
</ul>
|
|
<script>
|
|
function setAll(state) {
|
|
document.querySelectorAll('details').forEach(function (item) {
|
|
item.open = state;
|
|
});
|
|
}
|
|
|
|
document.getElementById('search').addEventListener('input', function () {
|
|
const term = this.value.trim().toLowerCase();
|
|
const nodes = Array.from(document.querySelectorAll('[data-path]'));
|
|
|
|
nodes.forEach(function (node) {
|
|
node.classList.remove('hidden');
|
|
});
|
|
|
|
if (!term) return;
|
|
|
|
nodes.forEach(function (node) {
|
|
if (!node.dataset.path.toLowerCase().includes(term)) {
|
|
node.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
nodes.filter(function (node) {
|
|
return node.dataset.path.toLowerCase().includes(term);
|
|
}).forEach(function (node) {
|
|
node.classList.remove('hidden');
|
|
let parent = node.parentElement;
|
|
|
|
while (parent) {
|
|
if (parent.tagName === 'DETAILS') {
|
|
parent.open = true;
|
|
parent.classList.remove('hidden');
|
|
}
|
|
if (parent.tagName === 'LI') {
|
|
parent.classList.remove('hidden');
|
|
}
|
|
parent = parent.parentElement;
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"@)
|
|
|
|
# 4. Save Output Files
|
|
$script:textLines \vert{} Set-Content -LiteralPath$TextFile -Encoding UTF8
|
|
$script:html.ToString() \vert{} Set-Content -LiteralPath$HtmlFile -Encoding UTF8
|
|
|
|
# Output Summary
|
|
Write-Host "`nScan Completed Successfully:" -ForegroundColor Green
|
|
Write-Host " Folders Scanned : $script:totalFolders"
|
|
Write-Host " Files Cataloged : $script:totalFiles"
|
|
Write-Host " Unreadable Errors: $script:scanErrors"
|
|
Write-Host " Text Report : $TextFile"
|
|
Write-Host " HTML Report : $HtmlFile`n"
|
|
|
|
if ($OpenInBrowser) {
|
|
Start-Process $HtmlFile
|
|
} |