Private/Export-WingetHtmlReport.ps1
|
function Export-WingetHtmlReport { <# .SYNOPSIS Exports an array of objects to a highly styled, interactive HTML report. .DESCRIPTION Writes an array of objects to a dark-mode HTML file with sortable columns and a live search filter. Callers are responsible for opening it. .PARAMETER Data The array of custom objects to export. .PARAMETER ReportTitle The title to display at the top of the report. .PARAMETER FilePath Where to write the .html file. Missing folders are created. #> [CmdletBinding()] param( [Parameter(Mandatory=$true)] [array]$Data, [Parameter(Mandatory=$true)] [string]$ReportTitle, [Parameter(Mandatory=$true)] [string]$FilePath ) if (-not $Data -or $Data.Count -eq 0) { Write-Warning "No data available to export to HTML." return } # Make sure the target folder exists (the default C:\temp often does not) $saveDir = Split-Path $FilePath -Parent if ($saveDir -and -not (Test-Path $saveDir)) { New-Item -ItemType Directory -Path $saveDir -Force | Out-Null } $titleHtml = [System.Net.WebUtility]::HtmlEncode($ReportTitle) Write-Host "Generating HTML report..." -ForegroundColor DarkGray # Extract column names from the first object $firstItem = $Data[0] $properties = if ($firstItem -is [PSCustomObject]) { $firstItem.PSObject.Properties.Name } elseif ($firstItem -is [Hashtable]) { $firstItem.Keys | Sort-Object } else { $firstItem | Get-Member -MemberType Properties | Select-Object -ExpandProperty Name } # Build Table Headers $thHtml = "" foreach ($prop in $properties) { $thHtml += "<th>$([System.Net.WebUtility]::HtmlEncode([string]$prop))</th>`n" } # Build Table Rows $trHtml = "" foreach ($item in $Data) { $trHtml += "<tr>`n" foreach ($prop in $properties) { $val = if ($item -is [Hashtable]) { $item[$prop] } else { $item.$prop } # Escape HTML $escapedVal = [System.Net.WebUtility]::HtmlEncode([string]$val) $trHtml += "<td>$escapedVal</td>`n" } $trHtml += "</tr>`n" } # HTML Template $htmlReport = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>$titleHtml - WingetBatch</title> <style> :root { --bg-base: #09090b; --bg-card: rgba(24, 24, 27, 0.6); --border: rgba(255, 255, 255, 0.1); --text-main: #f8fafc; --text-muted: #94a3b8; --accent: #10b981; --accent-hover: #059669; } body { background-color: var(--bg-base); color: var(--text-main); font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 2rem; min-height: 100vh; background-image: radial-gradient(circle at 15% 50%, rgba(16, 185, 129, 0.05), transparent 25%), radial-gradient(circle at 85% 30%, rgba(56, 189, 248, 0.05), transparent 25%); } .container { max-width: 1400px; margin: 0 auto; } header { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 2rem; border-bottom: 1px solid var(--border); padding-bottom: 1rem; } h1 { margin: 0; font-size: 2.5rem; font-weight: 700; letter-spacing: -0.025em; background: linear-gradient(to right, #fff, #94a3b8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .meta { color: var(--text-muted); font-size: 0.875rem; } .controls { margin-bottom: 1.5rem; display: flex; gap: 1rem; } input[type="text"] { flex-grow: 1; background: rgba(0,0,0,0.3); border: 1px solid var(--border); color: var(--text-main); padding: 0.75rem 1rem; border-radius: 0.5rem; font-size: 1rem; outline: none; transition: border-color 0.2s, box-shadow 0.2s; } input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } .table-container { background: var(--bg-card); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid var(--border); border-radius: 1rem; overflow: auto; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); } table { width: 100%; border-collapse: collapse; text-align: left; } th { background: rgba(255,255,255,0.02); color: var(--text-muted); font-weight: 600; font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 1rem; border-bottom: 1px solid var(--border); cursor: pointer; user-select: none; transition: background 0.2s; } th:hover { background: rgba(255,255,255,0.05); color: var(--text-main); } td { padding: 1rem; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 0.95rem; word-break: break-word; } tr:last-child td { border-bottom: none; } tr:hover td { background: rgba(255,255,255,0.02); } /* Sort indicators */ th::after { content: ''; display: inline-block; margin-left: 0.5rem; opacity: 0.3; } th.asc::after { content: '▲'; opacity: 1; color: var(--accent); } th.desc::after { content: '▼'; opacity: 1; color: var(--accent); } </style> </head> <body> <div class="container"> <header> <div> <h1>$titleHtml</h1> <div class="meta">Generated by WingetBatch • $((Get-Date).ToString("yyyy-MM-dd HH:mm:ss"))</div> </div> <div class="meta">$($Data.Count) records found</div> </header> <div class="controls"> <input type="text" id="searchInput" placeholder="Search across all fields..." onkeyup="filterTable()"> </div> <div class="table-container"> <table id="dataTable"> <thead> <tr> $thHtml </tr> </thead> <tbody> $trHtml </tbody> </table> </div> </div> <script> // Client-side search filtering function filterTable() { const input = document.getElementById("searchInput"); const filter = input.value.toLowerCase(); const table = document.getElementById("dataTable"); const tr = table.getElementsByTagName("tr"); for (let i = 1; i < tr.length; i++) { let textValue = tr[i].textContent || tr[i].innerText; if (textValue.toLowerCase().indexOf(filter) > -1) { tr[i].style.display = ""; } else { tr[i].style.display = "none"; } } } // Client-side column sorting const getCellValue = (tr, idx) => tr.children[idx].innerText || tr.children[idx].textContent; const comparer = (idx, asc) => (a, b) => ((v1, v2) => v1 !== '' && v2 !== '' && !isNaN(v1) && !isNaN(v2) ? v1 - v2 : v1.toString().localeCompare(v2) )(getCellValue(asc ? a : b, idx), getCellValue(asc ? b : a, idx)); document.querySelectorAll('th').forEach(th => th.addEventListener('click', (() => { const table = th.closest('table'); const tbody = table.querySelector('tbody'); const asc = th.classList.contains('asc'); // Remove sort classes from all headers table.querySelectorAll('th').forEach(el => { el.classList.remove('asc', 'desc'); }); // Add new sort class th.classList.add(asc ? 'desc' : 'asc'); Array.from(tbody.querySelectorAll('tr')) .sort(comparer(Array.from(th.parentNode.children).indexOf(th), !asc)) .forEach(tr => tbody.appendChild(tr)); }))); </script> </body> </html> "@ # Callers report the path and open the file themselves try { $htmlReport | Out-File -FilePath $FilePath -Encoding UTF8 -Force } catch { Write-Error "Failed to save HTML report: $_" } } |