Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
The format is based on and uses the types of changes according to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.4.0] - 2026-07-09

### Added

- Results history:
- New `Backup-SPSJsonFile` public function archives the current `Results\<FileName>.json` into `Results\history\` with a `yyyyMMdd-HHmm` timestamp before each audit overwrites it (pattern reused from SPSUserSync / SPSWeather).
- New `Clear-SPSLogFolder` public function — a single retention/rotation implementation reused for both the transcript logs (`*.log`) and the archived snapshots (`*.json`). `Retention = 0` disables pruning.
- HTML report:
- New `Export-SPSCleanDependenciesReport` public function renders the results JSON as a self-contained, dependency-free HTML report — one summary card per dependency category plus a filterable/sortable table per non-empty category, or a clean "nothing to clean" state. Uses an atomic write (temp file + `Move-Item`).
- New internal helpers (Private, not exported): `ConvertTo-SPSHtmlEncoded`, `Get-SPSReportHtmlHead`, `Get-SPSReportCardHtml`, `Get-SPSReportHtmlScript` (pattern reused from SPSUpdate.Common).
- `SPSCleanDependencies.ps1`:
- The audit run now archives the previous results, generates `Results\<FileName>.html`, and prunes old history/logs. The current `Results\<FileName>.json` stays stable (still the exact input consumed by `-Clean`); only the history copies are timestamped.
- New `-HistoryRetentionDays` (default 30) and `-LogRetentionDays` (default 180) parameters control retention; `0` disables pruning. [issue #8](https://github.com/luigilink/SPSCleanDependencies/issues/8)
- Pester coverage for the new functions and the audit-branch wiring.

## [1.3.0] - 2026-07-09

### Changed
Expand Down
22 changes: 8 additions & 14 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
# SPSCleanDependencies - Release Notes

## [1.3.0] - 2026-07-09
## [1.4.0] - 2026-07-09

### Changed
### Added

- Restructure the helper module into `src/Modules/SPSCleanDependencies.Common` (aligned on the `SPSUpdate` pattern):
- One function per file under `Public/`, internal SQL helpers under `Private/` (`Get-SQLMissing*`, no longer exported).
- A loader `.psm1` dot-sourcing `Private/` + `Public/`, and a clean `.psd1` manifest (`ModuleVersion` 1.3.0, correct `FunctionsToExport`) replacing the mis-named, never-imported `SPSCleanDependencies.util.util.psd1`.
- The former module-level classes / `ArrayList` collections / `jsonObject` are folded into `Get-SPSMissingServerDependencies`, so the module holds no mutable state.
- The entry script moves to `src/SPSCleanDependencies.ps1`, imports the module through its manifest, and sources its version from `(Get-Module SPSCleanDependencies.Common).Version`.
- The import-time prelude (administrator check, High Performance power plan, SharePoint snap-in load) moves from the module into the entry script, making the module import-safe by design.
- Behaviour on a real SharePoint farm is unchanged.

### CI

- Workflows adapted to the `src/` layout (`paths`, PSScriptAnalyzer targets, release ZIP of `src/` contents).
- Deprecated GitHub Actions bumped: `actions/checkout@v4` -> `v7`, `actions/upload-artifact@v4` -> `v7`, `softprops/action-gh-release@v2` -> `v3`.
- **Results history** — the audit no longer overwrites its output blindly. Before each run the current `Results\<FileName>.json` is archived into `Results\history\` with a `yyyyMMdd-HHmm` timestamp:
- `Backup-SPSJsonFile` (public) creates the timestamped copy.
- `Clear-SPSLogFolder` (public) is the single retention/rotation implementation, reused for both transcript logs (`*.log`) and archived snapshots (`*.json`). `Retention = 0` disables pruning.
- **HTML report** — `Export-SPSCleanDependenciesReport` (public) 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. Backed by internal helpers `ConvertTo-SPSHtmlEncoded`, `Get-SPSReportHtmlHead`, `Get-SPSReportCardHtml`, `Get-SPSReportHtmlScript` (pattern from SPSUpdate.Common).
- **Entry script** — the audit run now archives the previous results, writes `Results\<FileName>.html`, and prunes old history/logs. The current `Results\<FileName>.json` stays stable (still the exact input consumed by `-Clean`); only the history copies are timestamped. New `-HistoryRetentionDays` (default 30) and `-LogRetentionDays` (default 180) parameters control retention; `0` disables pruning.
- Pester coverage for the new functions and the audit-branch wiring.

A full list of changes in each version can be found in the [change log](CHANGELOG.md)
Original file line number Diff line number Diff line change
@@ -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 <prod> & "co"'
# DB &lt;prod&gt; &amp; &quot;co&quot;
#>
[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('&amp;') }
'<' { [void]$sb.Append('&lt;') }
'>' { [void]$sb.Append('&gt;') }
'"' { [void]$sb.Append('&quot;') }
"'" { [void]$sb.Append('&#39;') }
default { [void]$sb.Append($char) }
}
}
return $sb.ToString()
}
}
Original file line number Diff line number Diff line change
@@ -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 { "<div class=`"card-sub`">$encSub</div>" }
return "<div class=`"card$toneClass`"><div class=`"card-value`">$encValue</div><div class=`"card-label`">$encLabel</div>$subHtml</div>"
}
Original file line number Diff line number Diff line change
@@ -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 <head> block (no CDN, works offline on a SharePoint
server) with the embedded SPSCleanDependencies report stylesheet, followed by
the opening <body> tag.

.PARAMETER Title
Page title used in the <title> 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</title><style>$css</style></head><body>"
}
Original file line number Diff line number Diff line change
@@ -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 <script type="application/json"
id="spsReportData"> element) describing one or more sections. Each section has
an id, a list of columns and a list of rows. For every section the script wires
a per-section search box (filter) and clickable column headers (sort) against
the matching <table> rendered by Export-SPSCleanDependenciesReport. No external
dependency (works offline on a SharePoint server).
#>
[CmdletBinding()]
[OutputType([System.String])]
param ()

$js = @'
(function(){
var node = document.getElementById('spsReportData');
if (!node) { return; }
var data = JSON.parse(node.textContent || node.innerText);
var sections = data.sections || [];

sections.forEach(function(section){
var cols = section.columns || [];
var rows = section.rows || [];
var sortField = null, sortDir = 1, view = rows;
var search = document.getElementById('search-' + section.id);
var thead = document.getElementById('thead-' + section.id);
var tbody = document.getElementById('tbody-' + section.id);
var info = document.getElementById('info-' + section.id);
if (!thead || !tbody) { return; }

function isNum(c){ return c.type === 'num'; }

function buildHead(){
var tr = document.createElement('tr');
cols.forEach(function(c){
var th = document.createElement('th');
if (isNum(c)) { th.className = 'num'; }
th.textContent = c.label + ' \u2195';
th.addEventListener('click', function(){
if (sortField === c.field) { sortDir = -sortDir; } else { sortField = c.field; sortDir = 1; }
applySort(); render();
});
tr.appendChild(th);
});
thead.appendChild(tr);
}

function applyFilter(){
var q = (search && search.value || '').trim().toLowerCase();
if (!q) { view = rows; }
else {
view = rows.filter(function(r){
return cols.some(function(c){
var v = r[c.field];
return v != null && String(v).toLowerCase().indexOf(q) !== -1;
});
});
}
}

function applySort(){
if (!sortField) { return; }
var col = null;
cols.forEach(function(c){ if (c.field === sortField) { col = c; } });
var numeric = col && isNum(col);
view = view.slice().sort(function(a,b){
var x = a[sortField], y = b[sortField];
if (numeric) {
x = parseFloat(x); y = parseFloat(y);
if (isNaN(x)) { x = -Infinity; } if (isNaN(y)) { y = -Infinity; }
return (x - y) * sortDir;
}
x = x == null ? '' : String(x).toLowerCase();
y = y == null ? '' : String(y).toLowerCase();
if (x < y) { return -1 * sortDir; }
if (x > y) { return 1 * sortDir; }
return 0;
});
}

function render(){
tbody.innerHTML = '';
view.forEach(function(r){
var tr = document.createElement('tr');
cols.forEach(function(c){
var td = document.createElement('td');
if (isNum(c)) { td.className = 'num'; }
td.textContent = r[c.field] == null ? '' : r[c.field];
tr.appendChild(td);
});
tbody.appendChild(tr);
});
if (info) { info.textContent = view.length + ' / ' + rows.length + ' rows'; }
}

if (search) {
search.addEventListener('input', function(){ applyFilter(); applySort(); render(); });
}
buildHead(); render();
});
})();
'@

return "<script>$js</script>"
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading