From 189e6d6554aab3258191d64be630daf51070d931 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Thu, 9 Jul 2026 16:42:10 +0200 Subject: [PATCH 1/4] feat: add results history and HTML report functions to SPSCleanDependencies.Common Add the building blocks for issue #8 (results history + HTML report), reusing the conventions proven across the sibling luigilink projects: History (Public): - Backup-SPSJsonFile: archive the current results JSON into Results/history with a yyyyMMdd-HHmm timestamp before it is overwritten (pattern from SPSUserSync). - Clear-SPSLogFolder: single rotation implementation reused for transcript logs (*.log) and archived snapshots (*.json); Retention = 0 disables pruning. HTML report (Private helpers + Public exporter, pattern from SPSUpdate.Common): - ConvertTo-SPSHtmlEncoded, Get-SPSReportHtmlHead, Get-SPSReportCardHtml, Get-SPSReportHtmlScript. - Export-SPSCleanDependenciesReport: renders the results JSON as a self-contained, dependency-free HTML file - one summary card per dependency category plus a filterable/sortable table per non-empty category, or a clean 'nothing to clean' state. Atomic write (temp file + Move-Item). Bump the module manifest to 1.4.0 and export the three new public functions. Refs #8 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Private/ConvertTo-SPSHtmlEncoded.ps1 | 50 ++++ .../Private/Get-SPSReportCardHtml.ps1 | 45 ++++ .../Private/Get-SPSReportHtmlHead.ps1 | 55 +++++ .../Private/Get-SPSReportHtmlScript.ps1 | 109 +++++++++ .../Public/Backup-SPSJsonFile.ps1 | 68 ++++++ .../Public/Clear-SPSLogFolder.ps1 | 79 ++++++ .../Export-SPSCleanDependenciesReport.ps1 | 227 ++++++++++++++++++ .../SPSCleanDependencies.Common.psd1 | 5 +- 8 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 src/Modules/SPSCleanDependencies.Common/Private/ConvertTo-SPSHtmlEncoded.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Private/Get-SPSReportCardHtml.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Private/Get-SPSReportHtmlHead.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Private/Get-SPSReportHtmlScript.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Public/Backup-SPSJsonFile.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Public/Clear-SPSLogFolder.ps1 create mode 100644 src/Modules/SPSCleanDependencies.Common/Public/Export-SPSCleanDependenciesReport.ps1 diff --git a/src/Modules/SPSCleanDependencies.Common/Private/ConvertTo-SPSHtmlEncoded.ps1 b/src/Modules/SPSCleanDependencies.Common/Private/ConvertTo-SPSHtmlEncoded.ps1 new file mode 100644 index 0000000..a102397 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Private/ConvertTo-SPSHtmlEncoded.ps1 @@ -0,0 +1,50 @@ +function ConvertTo-SPSHtmlEncoded { + <# + .SYNOPSIS + HTML-encodes a string for safe insertion into generated HTML reports. + + .DESCRIPTION + Replaces the five characters that are significant in HTML markup + (& < > " ') with their entity equivalents. Used by + Export-SPSCleanDependenciesReport to neutralize values (database names, + site/web IDs, logins, messages) before baking them into the report. + + Returns an empty string for null or empty input. + + .PARAMETER Value + The string to encode. + + .EXAMPLE + ConvertTo-SPSHtmlEncoded -Value 'DB & "co"' + # DB <prod> & "co" + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [AllowEmptyString()] + [AllowNull()] + [System.String] + $Value + ) + + process { + if ([string]::IsNullOrEmpty($Value)) { + return '' + } + + $sb = [System.Text.StringBuilder]::new() + foreach ($char in $Value.ToCharArray()) { + switch ($char) { + '&' { [void]$sb.Append('&') } + '<' { [void]$sb.Append('<') } + '>' { [void]$sb.Append('>') } + '"' { [void]$sb.Append('"') } + "'" { [void]$sb.Append(''') } + default { [void]$sb.Append($char) } + } + } + return $sb.ToString() + } +} diff --git a/src/Modules/SPSCleanDependencies.Common/Private/Get-SPSReportCardHtml.ps1 b/src/Modules/SPSCleanDependencies.Common/Private/Get-SPSReportCardHtml.ps1 new file mode 100644 index 0000000..0a8d9d4 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Private/Get-SPSReportCardHtml.ps1 @@ -0,0 +1,45 @@ +function Get-SPSReportCardHtml { + <# + .SYNOPSIS + Builds the HTML for one summary "card" (a big number plus a label). + + .PARAMETER Value + The value shown as the big number. + + .PARAMETER Label + The label shown under the value. + + .PARAMETER Sub + Optional sub-label shown under the label. + + .PARAMETER Tone + Optional visual tone: '', 'accent', 'clean' (green) or 'alert' (red). + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + $Value, + + [Parameter(Mandatory = $true)] + [System.String] + $Label, + + [Parameter()] + [System.String] + $Sub = '', + + [Parameter()] + [ValidateSet('', 'accent', 'clean', 'alert')] + [System.String] + $Tone = '' + ) + + $encValue = ConvertTo-SPSHtmlEncoded -Value ("$Value") + $encLabel = ConvertTo-SPSHtmlEncoded -Value $Label + $encSub = ConvertTo-SPSHtmlEncoded -Value $Sub + $toneClass = if ([string]::IsNullOrEmpty($Tone)) { '' } else { " $Tone" } + $subHtml = if ([string]::IsNullOrEmpty($encSub)) { '' } else { "
$encSub
" } + return "
$encValue
$encLabel
$subHtml
" +} diff --git a/src/Modules/SPSCleanDependencies.Common/Private/Get-SPSReportHtmlHead.ps1 b/src/Modules/SPSCleanDependencies.Common/Private/Get-SPSReportHtmlHead.ps1 new file mode 100644 index 0000000..e112460 --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Private/Get-SPSReportHtmlHead.ps1 @@ -0,0 +1,55 @@ +function Get-SPSReportHtmlHead { + <# + .SYNOPSIS + Returns the document head (with the embedded stylesheet) and the opening body tag. + + .DESCRIPTION + Emits a self-contained block (no CDN, works offline on a SharePoint + server) with the embedded SPSCleanDependencies report stylesheet, followed by + the opening tag. + + .PARAMETER Title + Page title used in the element. + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Title + ) + + $css = @' +:root{--brand:#1f6fb2;--brand-dark:#155a91;--ink:#222;--muted:#666;--line:#e3e3e3;--zebra:#f7f9fb;--ok:#2e9b57;--warn:#c19c00;--alert:#c0392b} +*{box-sizing:border-box} +body{font-family:'Aptos','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;color:var(--ink);margin:0;padding:24px;background:#fff} +h1{color:var(--brand);font-size:22px;margin:0 0 4px} +h2{color:var(--brand);font-size:16px;margin:24px 0 8px;border-bottom:2px solid var(--brand);padding-bottom:4px} +h3{color:var(--brand-dark);font-size:13px;margin:0 0 6px} +.meta{color:var(--muted);font-size:12px;margin-bottom:16px} +.summary{background:#eef5fb;border:1px solid #cfe0ef;border-left:4px solid var(--brand);border-radius:6px;padding:16px;margin-bottom:8px} +.cards{display:flex;flex-wrap:wrap;gap:12px} +.card{background:#fff;border:1px solid var(--line);border-radius:6px;padding:12px 16px;min-width:120px} +.card-value{font-size:24px;font-weight:700;color:var(--brand)} +.card-label{font-size:12px;color:var(--muted)} +.card-sub{font-size:11px;color:var(--muted);margin-top:2px} +.card.accent{background:#eef5fb;border-color:#cfe0ef} +.card.clean .card-value{color:var(--ok)} +.card.alert .card-value{color:var(--alert)} +table{border-collapse:collapse;width:100%;font-size:12px} +th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top;word-break:break-word} +th{background:var(--brand);color:#fff;cursor:pointer;user-select:none;position:sticky;top:0} +td.num,th.num{text-align:right} +tbody tr:nth-child(even){background:var(--zebra)} +.controls{display:flex;justify-content:space-between;align-items:center;margin:12px 0;flex-wrap:wrap;gap:8px} +.search{padding:6px 10px;border:1px solid var(--line);border-radius:4px;font-size:13px;width:280px;max-width:100%} +.pager{display:flex;gap:8px;align-items:center;font-size:12px} +.pager button{padding:4px 10px;border:1px solid var(--line);background:#fff;border-radius:4px;cursor:pointer} +.pager button:disabled{opacity:.4;cursor:default} +.empty{color:var(--ok);font-size:13px;margin:8px 0 20px;font-weight:600} +.footer{color:var(--muted);font-size:11px;margin-top:24px;border-top:1px solid var(--line);padding-top:8px} +'@ + + return "<!DOCTYPE html><html lang=`"en`"><head><meta charset=`"utf-8`"><meta name=`"viewport`" content=`"width=device-width, initial-scale=1`"><title>$Title" +} diff --git a/src/Modules/SPSCleanDependencies.Common/Private/Get-SPSReportHtmlScript.ps1 b/src/Modules/SPSCleanDependencies.Common/Private/Get-SPSReportHtmlScript.ps1 new file mode 100644 index 0000000..939bc5f --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Private/Get-SPSReportHtmlScript.ps1 @@ -0,0 +1,109 @@ +function Get-SPSReportHtmlScript { + <# + .SYNOPSIS + Returns the vanilla-JavaScript block that renders the interactive per-category tables. + + .DESCRIPTION + Reads a single JSON payload (embedded in a " +} diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Backup-SPSJsonFile.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Backup-SPSJsonFile.ps1 new file mode 100644 index 0000000..4dd6a7d --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Public/Backup-SPSJsonFile.ps1 @@ -0,0 +1,68 @@ +function Backup-SPSJsonFile { + <# + .SYNOPSIS + Archives an existing results JSON snapshot into a history folder before it is overwritten. + + .DESCRIPTION + Backup-SPSJsonFile copies the file at -Path into -HistoryFolder, appending a + timestamp to the file name (e.g. CONTOSO-PROD-SPSE-20260709-1615.json). The + original file is left untouched so the caller can overwrite it with a fresh + audit afterwards. + + The function does not perform retention/rotation itself: call + Clear-SPSLogFolder -Extension '*.json' on the history folder to prune old + snapshots, reusing the toolkit's single rotation implementation. + + Returns the full path of the backup that was created, or $null when -Path does + not exist (first run, nothing to archive). + + .PARAMETER Path + The results JSON file about to be overwritten. + + .PARAMETER HistoryFolder + Destination folder for the timestamped copy. Created if missing. + + .PARAMETER TimeStamp + Timestamp string injected into the backup file name. Defaults to the current + date/time as yyyyMMdd-HHmm. Exposed mainly for deterministic testing. + + .EXAMPLE + $previous = Backup-SPSJsonFile -Path $pathJsonFile -HistoryFolder $historyFolder + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Path, + + [Parameter(Mandatory = $true)] + [System.String] + $HistoryFolder, + + [Parameter()] + [System.String] + $TimeStamp = (Get-Date -Format yyyyMMdd-HHmm) + ) + + if (-not (Test-Path -Path $Path)) { + Write-Verbose -Message "Backup-SPSJsonFile: no existing file to archive at '$Path'." + return $null + } + + if (-not (Test-Path -Path $HistoryFolder)) { + $null = New-Item -Path $HistoryFolder -ItemType Directory -Force + } + + $leaf = Split-Path -Path $Path -Leaf + $name = [System.IO.Path]::GetFileNameWithoutExtension($leaf) + $extension = [System.IO.Path]::GetExtension($leaf) + $backupName = "$name-$TimeStamp$extension" + $backupPath = Join-Path -Path $HistoryFolder -ChildPath $backupName + + Copy-Item -Path $Path -Destination $backupPath -Force + Write-Verbose -Message "Backup-SPSJsonFile: archived '$Path' to '$backupPath'." + + return $backupPath +} diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Clear-SPSLogFolder.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Clear-SPSLogFolder.ps1 new file mode 100644 index 0000000..873682c --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Public/Clear-SPSLogFolder.ps1 @@ -0,0 +1,79 @@ +function Clear-SPSLogFolder { + <# + .SYNOPSIS + Deletes old files from a folder based on a retention window. + + .DESCRIPTION + Clear-SPSLogFolder removes files older than the requested number of days from + the specified folder (recursively). The retention window is evaluated against + each file's LastWriteTime. It is the toolkit's single rotation implementation, + reused for both the transcript logs (Logs\) and the archived results snapshots + (Results\history\, via -Extension '*.json'). + + A Retention of 0 disables pruning (nothing is deleted). The function emits + banner lines on stdout so it stays visible inside the Start-Transcript output + produced by SPSCleanDependencies. + + .PARAMETER Path + Directory to scan. Subdirectories are scanned recursively. + + .PARAMETER Retention + Number of days to keep. Files older than this are deleted. Defaults to 90 days. + A value of 0 disables pruning. + + .PARAMETER Extension + File name pattern to filter on. Defaults to '*.log'. + + .EXAMPLE + Clear-SPSLogFolder -Path 'D:\Tools\Logs' -Retention 30 + + .EXAMPLE + Clear-SPSLogFolder -Path $historyFolder -Retention 30 -Extension '*.json' + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Path, + + [Parameter()] + [System.UInt32] + $Retention = 90, + + [Parameter()] + [System.String] + $Extension = '*.log' + ) + + if ($Retention -eq 0) { + Write-Verbose -Message 'Clear-SPSLogFolder: retention disabled (Retention = 0), nothing to prune.' + return + } + + if (-not (Test-Path -Path $Path)) { + return + } + + $lastWrite = (Get-Date).AddDays(-$Retention) + + $files = Get-ChildItem -Path $Path -Include $Extension -Recurse -File -ErrorAction SilentlyContinue | + Where-Object -FilterScript { $_.LastWriteTime -le $lastWrite } + + Write-Output '--------------------------------------------------------------' + if ($files) { + Write-Output "Cleaning files ($Extension) older than $Retention days in $Path ..." + foreach ($file in $files) { + if ($null -ne $file) { + if ($PSCmdlet.ShouldProcess($file.FullName, 'Remove file')) { + Write-Output "Deleting file $($file.FullName) ..." + Remove-Item -Path $file.FullName -Force -ErrorAction SilentlyContinue | Out-Null + } + } + } + } + else { + Write-Output "$Path - No files ($Extension) to delete" + } + Write-Output '--------------------------------------------------------------' +} diff --git a/src/Modules/SPSCleanDependencies.Common/Public/Export-SPSCleanDependenciesReport.ps1 b/src/Modules/SPSCleanDependencies.Common/Public/Export-SPSCleanDependenciesReport.ps1 new file mode 100644 index 0000000..ae6101c --- /dev/null +++ b/src/Modules/SPSCleanDependencies.Common/Public/Export-SPSCleanDependenciesReport.ps1 @@ -0,0 +1,227 @@ +function Export-SPSCleanDependenciesReport { + <# + .SYNOPSIS + Generates a self-contained HTML report from a SPSCleanDependencies results JSON. + + .DESCRIPTION + Export-SPSCleanDependenciesReport renders the results produced by + Get-SPSMissingServerDependencies (the Results\.json audit file) as a + single, dependency-free HTML file (no CDN, works offline on a SharePoint server). + + The report shows one summary card per dependency category (count of items found) + and, for every non-empty category, a filterable / sortable table of the items + with their identifying fields. When the audit found nothing, the report renders a + clean "no missing dependencies" state. + + The data can be supplied either as a file (-InputFile, the JSON results) or as an + already-parsed object (-InputObject). Returns the path of the report written. + + .PARAMETER InputFile + Path of the results JSON file to read. + + .PARAMETER InputObject + An already-parsed results object (as returned by ConvertFrom-Json). + + .PARAMETER OutputFile + Destination path of the generated .html file. + + .PARAMETER Title + Heading shown at the top of the report. Defaults to a generic title. + + .PARAMETER FarmName + Farm / file label shown in the metadata line (e.g. CONTOSO-PROD-SPSE). + + .PARAMETER Version + SPSCleanDependencies version stamped in the report footer. Defaults to the + SPSCleanDependencies.Common module version. + + .EXAMPLE + Export-SPSCleanDependenciesReport -InputFile $json -OutputFile $html -FarmName 'CONTOSO-PROD-SPSE' + #> + [CmdletBinding(DefaultParameterSetName = 'ByFile')] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true, ParameterSetName = 'ByFile')] + [System.String] + $InputFile, + + [Parameter(Mandatory = $true, ParameterSetName = 'ByObject')] + $InputObject, + + [Parameter(Mandatory = $true)] + [System.String] + $OutputFile, + + [Parameter()] + [System.String] + $Title, + + [Parameter()] + [System.String] + $FarmName, + + [Parameter()] + [System.String] + $Version + ) + + # ---- Load the results --------------------------------------------------------- + if ($PSCmdlet.ParameterSetName -eq 'ByFile') { + if (-not (Test-Path -Path $InputFile)) { + throw "Export-SPSCleanDependenciesReport: input file not found: $InputFile" + } + $raw = Get-Content -Path $InputFile -Raw -Encoding UTF8 + $results = if ([string]::IsNullOrWhiteSpace($raw)) { $null } else { $raw | ConvertFrom-Json } + } + else { + $results = $InputObject + } + + if ([string]::IsNullOrEmpty($Title)) { $Title = 'SPSCleanDependencies - Missing Server Dependencies Report' } + + if ([string]::IsNullOrEmpty($Version)) { + $moduleVersion = (Get-Module -Name SPSCleanDependencies.Common -ErrorAction SilentlyContinue).Version + $Version = if ($null -ne $moduleVersion) { $moduleVersion.ToString() } else { 'unknown' } + } + + # ---- Category metadata (JSON key -> label + table columns) -------------------- + $categories = @( + [PSCustomObject]@{ Key = 'MissingFeature'; Label = 'Missing Features'; Columns = @( + @{ field = 'Database'; label = 'Database'; type = 'text' } + @{ field = 'FeatureID'; label = 'Feature ID'; type = 'text' } + @{ field = 'SiteID'; label = 'Site ID'; type = 'text' } + @{ field = 'Path'; label = 'Path'; type = 'text' } + ) } + [PSCustomObject]@{ Key = 'MissingWebPart'; Label = 'Missing WebParts'; Columns = @( + @{ field = 'Database'; label = 'Database'; type = 'text' } + @{ field = 'WebPartID'; label = 'WebPart ID'; type = 'text' } + @{ field = 'ClassName'; label = 'Class'; type = 'text' } + @{ field = 'SiteID'; label = 'Site ID'; type = 'text' } + @{ field = 'WebID'; label = 'Web ID'; type = 'text' } + @{ field = 'DirName'; label = 'Dir'; type = 'text' } + @{ field = 'LeafName'; label = 'Page'; type = 'text' } + ) } + [PSCustomObject]@{ Key = 'MissingSetupFile'; Label = 'Missing Setup Files'; Columns = @( + @{ field = 'Database'; label = 'Database'; type = 'text' } + @{ field = 'SetupPath'; label = 'Setup Path'; type = 'text' } + @{ field = 'FileID'; label = 'File ID'; type = 'text' } + @{ field = 'SiteID'; label = 'Site ID'; type = 'text' } + @{ field = 'WebID'; label = 'Web ID'; type = 'text' } + ) } + [PSCustomObject]@{ Key = 'MissingAssembly'; Label = 'Missing Assemblies'; Columns = @( + @{ field = 'Database'; label = 'Database'; type = 'text' } + @{ field = 'AssemblyInfo'; label = 'Assembly'; type = 'text' } + @{ field = 'AssemblyID'; label = 'Assembly ID'; type = 'text' } + @{ field = 'HostType'; label = 'Host Type'; type = 'text' } + @{ field = 'HostID'; label = 'Host ID'; type = 'text' } + @{ field = 'SiteID'; label = 'Site ID'; type = 'text' } + @{ field = 'WebID'; label = 'Web ID'; type = 'text' } + ) } + [PSCustomObject]@{ Key = 'Configuration'; Label = 'Configuration (Classic Auth Admins)'; Columns = @( + @{ field = 'Database'; label = 'Database'; type = 'text' } + @{ field = 'SiteID'; label = 'Site ID'; type = 'text' } + @{ field = 'Login'; label = 'Login'; type = 'text' } + ) } + [PSCustomObject]@{ Key = 'MissingSiteDefinition'; Label = 'Missing Site Definitions (audit only)'; Columns = @( + @{ field = 'Database'; label = 'Database'; type = 'text' } + @{ field = 'Message'; label = 'Message'; type = 'text' } + @{ field = 'Remedy'; label = 'Remedy'; type = 'text' } + ) } + [PSCustomObject]@{ Key = 'SiteOrphan'; Label = 'Orphaned Sites'; Columns = @( + @{ field = 'Database'; label = 'Database'; type = 'text' } + @{ field = 'SiteID'; label = 'Site ID'; type = 'text' } + ) } + ) + + # ---- Build cards, sections and JSON payload ----------------------------------- + $cards = @() + $sectionsHtml = '' + $payloadSections = @() + $totalItems = 0 + + foreach ($category in $categories) { + $items = @() + if ($null -ne $results -and ($results.PSObject.Properties.Name -contains $category.Key)) { + $items = @($results.$($category.Key) | Where-Object { $null -ne $_ }) + } + $count = $items.Count + $totalItems += $count + + $tone = if ($count -eq 0) { 'clean' } else { 'alert' } + $cards += (Get-SPSReportCardHtml -Value $count -Label $category.Label -Tone $tone) + + if ($count -eq 0) { continue } + + $sectionId = $category.Key + $rows = foreach ($item in $items) { + $ordered = [ordered]@{} + foreach ($col in $category.Columns) { + $ordered[$col.field] = "$($item.$($col.field))" + } + [PSCustomObject]$ordered + } + + $payloadSections += [ordered]@{ + id = $sectionId + columns = $category.Columns + rows = @($rows) + } + + $encLabel = ConvertTo-SPSHtmlEncoded -Value $category.Label + $sectionsHtml += "

$encLabel ($count)

" + + "
" + + "
" + } + + $payload = [ordered]@{ sections = @($payloadSections) } + $json = $payload | ConvertTo-Json -Depth 6 -Compress + # Neutralize any sequence that could break out of the " + + (Get-SPSReportHtmlScript) + + '' + + # ---- Atomic write ------------------------------------------------------------- + $outDir = Split-Path -Path $OutputFile -Parent + if (-not [string]::IsNullOrEmpty($outDir) -and -not (Test-Path -Path $outDir)) { + $null = New-Item -Path $outDir -ItemType Directory -Force + } + + $encoding = [System.Text.UTF8Encoding]::new($true) + $tmpPath = '{0}.tmp.{1}' -f $OutputFile, ([guid]::NewGuid().ToString('N')) + [System.IO.File]::WriteAllText($tmpPath, $html, $encoding) + Move-Item -Path $tmpPath -Destination $OutputFile -Force + + return $OutputFile +} diff --git a/src/Modules/SPSCleanDependencies.Common/SPSCleanDependencies.Common.psd1 b/src/Modules/SPSCleanDependencies.Common/SPSCleanDependencies.Common.psd1 index 2d301eb..23aeaec 100644 --- a/src/Modules/SPSCleanDependencies.Common/SPSCleanDependencies.Common.psd1 +++ b/src/Modules/SPSCleanDependencies.Common/SPSCleanDependencies.Common.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'SPSCleanDependencies.Common.psm1' - ModuleVersion = '1.3.0' + ModuleVersion = '1.4.0' GUID = 'aa259ca2-c421-487d-80ec-b7d7b440c584' Author = 'Jean-Cyril DROUHIN' CompanyName = 'luigilink' @@ -10,6 +10,9 @@ PowerShellVersion = '5.1' FunctionsToExport = @( + 'Backup-SPSJsonFile' + 'Clear-SPSLogFolder' + 'Export-SPSCleanDependenciesReport' 'Get-SPSInstalledProductVersion' 'Get-SPSMissingServerDependencies' 'Remove-SPSMissingAssembly' From a02e39210670bc1059cddb2b7cfbdea2d2c768b8 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Thu, 9 Jul 2026 16:43:03 +0200 Subject: [PATCH 2/4] feat: wire history archiving and HTML report into the audit run In the audit branch (no -Clean), the entry script now: - archives the previous Results\.json into Results\history (timestamped) before the fresh audit overwrites it; - generates a Results\.html report from the new results; - prunes old history snapshots and transcript logs. The current Results\.json is kept stable (it remains the exact input consumed by the -Clean run); only the history copies are timestamped. Retention is configurable via the new -HistoryRetentionDays (default 30) and -LogRetentionDays (default 180) parameters; 0 disables pruning. Refs #8 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/SPSCleanDependencies.ps1 | 39 +++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/SPSCleanDependencies.ps1 b/src/SPSCleanDependencies.ps1 index cc7818e..f36a692 100644 --- a/src/SPSCleanDependencies.ps1 +++ b/src/SPSCleanDependencies.ps1 @@ -16,6 +16,15 @@ .PARAMETER Clean Use the switch Clean parameter if you want to clean up Missing server side dependencies + .PARAMETER HistoryRetentionDays + Number of days of archived result snapshots to keep in the Results\history folder. + Before each audit the current Results\.json is copied there with a + timestamp; snapshots older than this are pruned. Defaults to 30. Set to 0 to disable pruning. + + .PARAMETER LogRetentionDays + Number of days of transcript log files to keep in the Logs folder. Defaults to 180. + Set to 0 to disable pruning. + .EXAMPLE SPSCleanDependencies.ps1 -FileName 'CONTOSO-PROD-SP2019' This command will create a JSON file in the Results folder with the name CONTOSO-PROD-SP2019.json @@ -38,7 +47,15 @@ param( [Parameter(Position = 2)] [switch] - $Clean + $Clean, + + [Parameter()] + [System.UInt32] + $HistoryRetentionDays = 30, + + [Parameter()] + [System.UInt32] + $LogRetentionDays = 180 ) #requires -Version 5.1 @@ -125,6 +142,8 @@ if (-not(Test-Path $pathResultsFolder)) { } $pathLogFile = Join-Path -Path $pathLogsFolder -ChildPath "$($FileName)_$([datetime]::Now.ToString('yyyyMMddHHmmss')).log" $pathJsonFile = Join-Path -Path $pathResultsFolder -ChildPath ("$($FileName).json") +$pathHtmlFile = Join-Path -Path $pathResultsFolder -ChildPath ("$($FileName).html") +$pathHistoryFolder = Join-Path -Path $pathResultsFolder -ChildPath 'history' $DateStarted = Get-date $psVersion = ($host).Version.ToString() @@ -220,7 +239,25 @@ if ($Clean) { } else { Write-Output 'Getting Missing Server Side Dependencies' + + # Archive the previous results snapshot (if any) before it is overwritten, + # so a history of past audits is kept under Results\history. + [void](Backup-SPSJsonFile -Path $pathJsonFile -HistoryFolder $pathHistoryFolder) + Get-SPSMissingServerDependencies -Path $pathJsonFile + + # Generate a self-contained HTML report from the fresh results. + if (Test-Path $pathJsonFile) { + Write-Output "Generating HTML report: $pathHtmlFile" + [void](Export-SPSCleanDependenciesReport -InputFile $pathJsonFile ` + -OutputFile $pathHtmlFile ` + -FarmName $FileName ` + -Version $SPSCleanDependenciesVersion) + } + + # Prune old history snapshots and transcript logs. + Clear-SPSLogFolder -Path $pathHistoryFolder -Retention $HistoryRetentionDays -Extension '*.json' + Clear-SPSLogFolder -Path $pathLogsFolder -Retention $LogRetentionDays -Extension '*.log' } #endregion From 97456a42d920dcc13750fd573a727cb46194930c Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Thu, 9 Jul 2026 16:44:43 +0200 Subject: [PATCH 3/4] test: cover history and HTML report functions Extend the Pester suite for the 1.4.0 feature: - Module tests: add Backup-SPSJsonFile (no-op on missing source, timestamped archive leaving the original in place), Clear-SPSLogFolder (retention prune, Retention=0 no-op, ShouldProcess), Export-SPSCleanDependenciesReport (section per non-empty category, HTML-encoding of result values, clean empty state, throw on missing input) and the private HTML helpers; refresh the exported-set and FunctionsToExport assertions. - Entry-script tests: assert the new -HistoryRetentionDays/-LogRetentionDays parameters and the audit-branch wiring (Backup-SPSJsonFile, Export-SPSCleanDependenciesReport, Clear-SPSLogFolder). 100/100 green locally. Refs #8 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SPSCleanDependencies.Common.Tests.ps1 | 140 ++++++++++++++++++ tests/SPSCleanDependencies.Tests.ps1 | 30 +++- 2 files changed, 168 insertions(+), 2 deletions(-) diff --git a/tests/Modules/SPSCleanDependencies.Common.Tests.ps1 b/tests/Modules/SPSCleanDependencies.Common.Tests.ps1 index a66f1db..faaf877 100644 --- a/tests/Modules/SPSCleanDependencies.Common.Tests.ps1 +++ b/tests/Modules/SPSCleanDependencies.Common.Tests.ps1 @@ -62,6 +62,9 @@ Describe 'SPSCleanDependencies.Common Module' { Describe 'SPSCleanDependencies.Common Public Functions' { $publicFunctions = @( + 'Backup-SPSJsonFile', + 'Clear-SPSLogFolder', + 'Export-SPSCleanDependenciesReport', 'Get-SPSInstalledProductVersion', 'Get-SPSMissingServerDependencies', 'Remove-SPSMissingFeature', @@ -78,6 +81,9 @@ Describe 'SPSCleanDependencies.Common Public Functions' { It 'manifest FunctionsToExport matches the exported set' { $expectedExports = @( + 'Backup-SPSJsonFile', + 'Clear-SPSLogFolder', + 'Export-SPSCleanDependenciesReport', 'Get-SPSInstalledProductVersion', 'Get-SPSMissingServerDependencies', 'Remove-SPSMissingFeature', @@ -114,6 +120,140 @@ Describe 'SPSCleanDependencies.Common Private SQL Helpers' { } } +Describe 'SPSCleanDependencies.Common Private HTML Report Helpers' { + + $htmlHelpers = @( + 'ConvertTo-SPSHtmlEncoded', + 'Get-SPSReportHtmlHead', + 'Get-SPSReportCardHtml', + 'Get-SPSReportHtmlScript' + ) + + It 'defines internal helper <_> (module scope)' -ForEach $htmlHelpers { + $helper = $_ + InModuleScope -ModuleName 'SPSCleanDependencies.Common' -Parameters @{ helper = $helper } { + param($helper) + Get-Command -Name $helper -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + } + } + + It 'does not export the HTML helpers to callers <_>' -ForEach $htmlHelpers { + (Get-Module -Name $script:moduleName).ExportedFunctions.Keys | Should -Not -Contain $_ + } + + It 'ConvertTo-SPSHtmlEncoded escapes HTML metacharacters' { + InModuleScope -ModuleName 'SPSCleanDependencies.Common' { + ConvertTo-SPSHtmlEncoded -Value 'a&"x''y' | Should -Be 'a<b>&"x'y' + } + } +} + +Describe 'Backup-SPSJsonFile' { + + BeforeEach { + $script:tempRoot = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString('N')) + $null = New-Item -Path $script:tempRoot -ItemType Directory -Force + $script:jsonPath = Join-Path -Path $script:tempRoot -ChildPath 'CONTOSO-PROD-SPSE.json' + $script:historyFolder = Join-Path -Path $script:tempRoot -ChildPath 'history' + } + + It 'returns $null and creates nothing when the source does not exist' { + $result = Backup-SPSJsonFile -Path $script:jsonPath -HistoryFolder $script:historyFolder + $result | Should -BeNullOrEmpty + Test-Path $script:historyFolder | Should -BeFalse + } + + It 'archives an existing file with a timestamped name and leaves the original in place' { + '{}' | Set-Content -Path $script:jsonPath + $result = Backup-SPSJsonFile -Path $script:jsonPath -HistoryFolder $script:historyFolder -TimeStamp '20260709-1615' + $result | Should -Not -BeNullOrEmpty + Split-Path $result -Leaf | Should -Be 'CONTOSO-PROD-SPSE-20260709-1615.json' + Test-Path $result | Should -BeTrue + Test-Path $script:jsonPath | Should -BeTrue + } +} + +Describe 'Clear-SPSLogFolder' { + + BeforeEach { + $script:folder = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString('N')) + $null = New-Item -Path $script:folder -ItemType Directory -Force + $script:oldFile = Join-Path -Path $script:folder -ChildPath 'old.json' + $script:newFile = Join-Path -Path $script:folder -ChildPath 'new.json' + '{}' | Set-Content -Path $script:oldFile + '{}' | Set-Content -Path $script:newFile + (Get-Item $script:oldFile).LastWriteTime = (Get-Date).AddDays(-40) + } + + It 'declares SupportsShouldProcess' { + $params = (Get-Command Clear-SPSLogFolder).Parameters.Keys + $params | Should -Contain 'WhatIf' + $params | Should -Contain 'Confirm' + } + + It 'removes files older than the retention window and keeps recent ones' { + Clear-SPSLogFolder -Path $script:folder -Retention 30 -Extension '*.json' | Out-Null + Test-Path $script:oldFile | Should -BeFalse + Test-Path $script:newFile | Should -BeTrue + } + + It 'does nothing when Retention is 0' { + Clear-SPSLogFolder -Path $script:folder -Retention 0 -Extension '*.json' | Out-Null + Test-Path $script:oldFile | Should -BeTrue + Test-Path $script:newFile | Should -BeTrue + } +} + +Describe 'Export-SPSCleanDependenciesReport' { + + BeforeEach { + $script:reportRoot = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString('N')) + $null = New-Item -Path $script:reportRoot -ItemType Directory -Force + $script:inJson = Join-Path -Path $script:reportRoot -ChildPath 'in.json' + $script:outHtml = Join-Path -Path $script:reportRoot -ChildPath 'out.html' + } + + It 'renders a self-contained report with a section per non-empty category' { + $sample = [ordered]@{ + MissingFeature = @(@{ Database = 'WSS'; FeatureID = 'f1'; SiteID = 's1'; Path = '/a' }) + SiteOrphan = @(@{ Database = 'WSS'; SiteID = 's2' }) + } + $sample | ConvertTo-Json -Depth 6 | Set-Content -Path $script:inJson -Encoding UTF8 + + $out = Export-SPSCleanDependenciesReport -InputFile $script:inJson -OutputFile $script:outHtml -FarmName 'CONTOSO-PROD-SPSE' + $out | Should -Be $script:outHtml + Test-Path $script:outHtml | Should -BeTrue + + $html = Get-Content -Path $script:outHtml -Raw + $html | Should -Match '^\ufeff?' + $html.TrimEnd() | Should -Match '$' + $html | Should -Match 'thead-MissingFeature' + $html | Should -Match 'thead-SiteOrphan' + $html | Should -Match 'Total items' + } + + It 'HTML-encodes values coming from the results (no raw markup leaks)' { + $sample = [ordered]@{ + MissingWebPart = @(@{ Database = 'WSS'; WebPartID = 'wp'; ClassName = 'Contoso.WP