windowsweep 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI-INTEGRATION-GUIDE.md +15 -11
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -21
- package/README.md +70 -42
- package/VERSION +1 -1
- package/lib/actions.ps1 +212 -212
- package/lib/config.ps1 +108 -99
- package/lib/constants.ps1 +103 -93
- package/lib/fs.ps1 +189 -180
- package/lib/log.ps1 +140 -140
- package/lib/safety.ps1 +541 -493
- package/lib/scan.ps1 +109 -100
- package/lib/ui.ps1 +275 -275
- package/modules/android_avd.ps1 +36 -36
- package/modules/app_caches.ps1 +58 -58
- package/modules/browsers.ps1 +33 -33
- package/modules/build_tools.ps1 +35 -35
- package/modules/crash_trap.ps1 +89 -89
- package/modules/disk_usage.ps1 +49 -49
- package/modules/docker.ps1 +93 -93
- package/modules/editors.ps1 +150 -150
- package/modules/globals_audit.ps1 +236 -236
- package/modules/health.ps1 +101 -101
- package/modules/installed_programs.ps1 +118 -118
- package/modules/menu.ps1 +63 -63
- package/modules/notify.ps1 +60 -60
- package/modules/orphaned_appdata.ps1 +189 -189
- package/modules/personal.ps1 +126 -126
- package/modules/pkg_managers.ps1 +73 -73
- package/modules/projects.ps1 +160 -160
- package/modules/release_helpers.ps1 +438 -438
- package/modules/reports.ps1 +212 -212
- package/modules/runner.ps1 +242 -239
- package/modules/self_test_extra.ps1 +537 -360
- package/modules/startup_audit.ps1 +126 -126
- package/modules/system_admin.ps1 +220 -220
- package/modules/test_runners.ps1 +24 -24
- package/modules/walkthrough.ps1 +80 -79
- package/modules/windows_user.ps1 +118 -118
- package/package.json +2 -2
- package/windowsweep.ps1 +322 -316
package/modules/reports.ps1
CHANGED
|
@@ -1,212 +1,212 @@
|
|
|
1
|
-
# reports.ps1 - list, view, export (Markdown / HTML) and summarise past JSON reports. No external tools needed.
|
|
2
|
-
|
|
3
|
-
function Get-ReportFiles {
|
|
4
|
-
$dir = $Script:WS.ReportsDir
|
|
5
|
-
if (-not (Test-Path -LiteralPath $dir)) { return @() }
|
|
6
|
-
return @(Get-ChildItem -LiteralPath $dir -Filter 'report-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name -Descending)
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function Read-Report {
|
|
10
|
-
param([string] $Path)
|
|
11
|
-
try { return (Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop) } catch { return $null }
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function Show-ReportList {
|
|
15
|
-
$files = @(Get-ReportFiles)
|
|
16
|
-
if ($files.Count -eq 0) { Write-Info 'no reports yet -
|
|
17
|
-
Write-UiLine (" {0,3} {1,-34} {2,8} {3,-17} {4,-12} {5}" -f '#', 'REPORT', 'SIZE', 'STARTED', 'RECLAIMED', 'MODE') 'White'
|
|
18
|
-
$i = 1
|
|
19
|
-
foreach ($f in $files) {
|
|
20
|
-
$r = Read-Report $f.FullName
|
|
21
|
-
$started = '?'; $rec = '?'; $mode = '?'
|
|
22
|
-
if ($r) {
|
|
23
|
-
$started = ([string]$r.meta.started_at).Substring(0, [math]::Min(16, ([string]$r.meta.started_at).Length)).Replace('T', ' ')
|
|
24
|
-
$rec = $r.totals.total_reclaimed_human
|
|
25
|
-
if ($r.meta.dry_run) { $rec = "~$($r.totals.total_estimated_human) (dry)" }
|
|
26
|
-
$mode = $r.meta.mode
|
|
27
|
-
}
|
|
28
|
-
Write-UiLine (" {0,3} {1,-34} {2,8} {3,-17} {4,-12} {5}" -f $i, $f.Name, (Format-Bytes $f.Length), $started, $rec, $mode) 'Gray'
|
|
29
|
-
$i++
|
|
30
|
-
}
|
|
31
|
-
return $files
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function ConvertTo-HtmlText { param([string] $Text) return [System.Net.WebUtility]::HtmlEncode([string]$Text) }
|
|
35
|
-
|
|
36
|
-
function Convert-ReportToMarkdown {
|
|
37
|
-
param([string] $Json, [string] $Out = '')
|
|
38
|
-
$r = Read-Report $Json
|
|
39
|
-
if (-not $r) { Write-Err "cannot read $Json"; return $null }
|
|
40
|
-
if (-not $Out) { $Out = [IO.Path]::ChangeExtension($Json, '.md') }
|
|
41
|
-
$dry = 'no'
|
|
42
|
-
if ($r.meta.dry_run) { $dry = 'yes' }
|
|
43
|
-
$l = @()
|
|
44
|
-
$l += "# $Script:WS_NAME session report"; $l += ''
|
|
45
|
-
$l += "_Generated from ``$([IO.Path]::GetFileName($Json))``_"; $l += ''
|
|
46
|
-
$l += '##
|
|
47
|
-
$l += "| Started | $($r.meta.started_at) |"; $l += "| Finished | $($r.meta.finished_at) |"; $l += "| Duration | $($r.meta.duration_seconds)
|
|
48
|
-
$l += "| Host | $($r.meta.host) |"; $l += "| User | $($r.meta.user) |"; $l += "| Windows | $($r.meta.os) |"; $l += "| Mode | $($r.meta.mode) |"
|
|
49
|
-
$l += "| Dry-run | $dry |"; $l += "| Elevated | $($r.meta.elevated) |"; $l += "| Developer mode | $($r.meta.developer_mode) |"; $l += "| Idle window | $($r.meta.idle_days) days |"
|
|
50
|
-
$l += ''; $l += '## Result'; $l += ''
|
|
51
|
-
if ($r.meta.dry_run) { $l += "**Would
|
|
52
|
-
$l += "Sections run: $($r.totals.steps_run) - skipped
|
|
53
|
-
$l += '## Drives'; $l += ''; $l += '| Drive |
|
|
54
|
-
foreach ($b in $r.disk.before) {
|
|
55
|
-
$a = $r.disk.after | Where-Object { $_.drive -eq $b.drive } | Select-Object -First 1
|
|
56
|
-
$afterFree = '?'
|
|
57
|
-
if ($a) { $afterFree = Format-Bytes $a.free_bytes }
|
|
58
|
-
$l += "| $($b.drive) | $(Format-Bytes $b.free_bytes) | $afterFree | $(Format-Bytes $b.size_bytes) |"
|
|
59
|
-
}
|
|
60
|
-
$l += ''; $l += '##
|
|
61
|
-
foreach ($s in $r.steps) { $l += "| $($s.n) | $($s.section) | $($s.title) | $($s.status) | $(Format-Bytes $s.freed_bytes) |" }
|
|
62
|
-
$l += ''; $l += "_${Script:WS_NAME} v$($r.meta.tool_version) by $($r.credits.author.name) - $($r.credits.tool_homepage)_"
|
|
63
|
-
[IO.File]::WriteAllLines($Out, $l)
|
|
64
|
-
Write-Ok "wrote $Out"
|
|
65
|
-
return $Out
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function Convert-ReportToHtml {
|
|
69
|
-
param([string] $Json, [string] $Out = '')
|
|
70
|
-
$r = Read-Report $Json
|
|
71
|
-
if (-not $r) { Write-Err "cannot read $Json"; return $null }
|
|
72
|
-
if (-not $Out) { $Out = [IO.Path]::ChangeExtension($Json, '.html') }
|
|
73
|
-
$headline = "Reclaimed"; $big = $r.totals.total_reclaimed_human
|
|
74
|
-
if ($r.meta.dry_run) { $headline = 'Would
|
|
75
|
-
$rows = ''
|
|
76
|
-
foreach ($s in $r.steps) {
|
|
77
|
-
$cls = ([string]$s.status) -replace '[^a-z-]', ''
|
|
78
|
-
$rows += "<tr class=`"row-$cls`"><td>$($s.n)</td><td>$($s.section)</td><td>$(ConvertTo-HtmlText $s.title)</td><td><span class=`"badge $cls`">$(ConvertTo-HtmlText $s.status)</span></td><td class=`"num`">$(ConvertTo-HtmlText (Format-Bytes $s.freed_bytes))</td></tr>`n"
|
|
79
|
-
}
|
|
80
|
-
$drives = ''
|
|
81
|
-
foreach ($b in $r.disk.before) {
|
|
82
|
-
$a = $r.disk.after | Where-Object { $_.drive -eq $b.drive } | Select-Object -First 1
|
|
83
|
-
$afterFree = '?'
|
|
84
|
-
if ($a) { $afterFree = Format-Bytes $a.free_bytes }
|
|
85
|
-
$drives += "<tr><td>$(ConvertTo-HtmlText $b.drive)</td><td class=`"num`">$(Format-Bytes $b.size_bytes)</td><td class=`"num`">$(Format-Bytes $b.free_bytes)</td><td class=`"num`">$afterFree</td></tr>`n"
|
|
86
|
-
}
|
|
87
|
-
$html = @"
|
|
88
|
-
<!doctype html>
|
|
89
|
-
<html lang="en">
|
|
90
|
-
<head>
|
|
91
|
-
<meta charset="utf-8">
|
|
92
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
93
|
-
<title>$Script:WS_NAME report - $(ConvertTo-HtmlText $r.meta.started_at)</title>
|
|
94
|
-
<style>
|
|
95
|
-
:root{--fg:#1c1c1f;--bg:#fafafa;--muted:#7a7a85;--ok:#0a7d54;--warn:#b8860b;--line:#e6e6ea;--card:#fff;--accent:#0b6bcb}
|
|
96
|
-
@media (prefers-color-scheme:dark){:root{--fg:#eaeaea;--bg:#16161a;--muted:#9d9da8;--line:#2a2a30;--card:#1f1f25;--accent:#5aa9ff}}
|
|
97
|
-
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.55 'Segoe UI',system-ui,sans-serif}
|
|
98
|
-
.wrap{max-width:960px;margin:2rem auto;padding:0 1.25rem}h1{font-size:1.6rem;margin:0 0 .3rem}
|
|
99
|
-
h2{font-size:1.05rem;margin:2.2rem 0 .7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
|
|
100
|
-
.subtitle{color:var(--muted);margin:0 0 1.5rem}.totals{display:flex;align-items:baseline;gap:1rem;padding:1.5rem 1.75rem;border:1px solid var(--line);border-radius:12px;background:var(--card);margin:1rem 0}
|
|
101
|
-
.totals .big{font-size:2.5rem;font-weight:800;color:var(--ok);line-height:1}.totals .label{color:var(--muted);text-transform:uppercase;font-size:.75rem;letter-spacing:.08em}
|
|
102
|
-
dl{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.6rem 1.5rem;margin:0;padding:1rem 1.25rem;border:1px solid var(--line);border-radius:10px;background:var(--card)}
|
|
103
|
-
dt{color:var(--muted);font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}dd{margin:.1rem 0 0;font-weight:600;word-break:break-all}
|
|
104
|
-
table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
|
105
|
-
th,td{text-align:left;padding:.55rem .85rem;border-bottom:1px solid var(--line)}tr:last-child td{border-bottom:none}
|
|
106
|
-
th{font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;background:var(--bg)}td.num{text-align:right;font-variant-numeric:tabular-nums}
|
|
107
|
-
.badge{display:inline-block;padding:.12em .5em;border-radius:999px;font-size:.75em;font-weight:600;text-transform:uppercase}
|
|
108
|
-
.badge.ran,.badge.dry-run{background:rgba(10,125,84,.12);color:var(--ok)}.badge.skipped,.badge.refused{background:rgba(184,134,11,.12);color:var(--warn)}.badge.failed{background:rgba(192,57,43,.12);color:#c0392b}
|
|
109
|
-
footer{margin:3rem 0 1rem;color:var(--muted);font-size:.85rem;text-align:center}a{color:var(--accent)}
|
|
110
|
-
</style>
|
|
111
|
-
</head>
|
|
112
|
-
<body><div class="wrap">
|
|
113
|
-
<h1>$Script:WS_NAME session report</h1>
|
|
114
|
-
<p class="subtitle">$(ConvertTo-HtmlText $r.meta.started_at) to $(ConvertTo-HtmlText $r.meta.finished_at) - $($r.meta.duration_seconds)s - $(ConvertTo-HtmlText $r.meta.host)</p>
|
|
115
|
-
<div class="totals"><div><div class="label">$headline</div><div class="big">$(ConvertTo-HtmlText $big)</div></div>
|
|
116
|
-
<div style="margin-left:auto;text-align:right;color:var(--muted)"><div><strong>$($r.totals.steps_run)</strong> sections run</div><div><strong>$($r.totals.steps_skipped)</strong> skipped</div></div></div>
|
|
117
|
-
<h2>Run</h2>
|
|
118
|
-
<dl><div><dt>Mode</dt><dd>$(ConvertTo-HtmlText $r.meta.mode)</dd></div><div><dt>Dry-run</dt><dd>$($r.meta.dry_run)</dd></div><div><dt>Elevated</dt><dd>$($r.meta.elevated)</dd></div>
|
|
119
|
-
<div><dt>Developer mode</dt><dd>$($r.meta.developer_mode)</dd></div><div><dt>Idle window</dt><dd>$($r.meta.idle_days) days</dd></div><div><dt>Windows</dt><dd>$(ConvertTo-HtmlText $r.meta.os)</dd></div>
|
|
120
|
-
<div><dt>Log file</dt><dd><code>$(ConvertTo-HtmlText $r.meta.log_file)</code></dd></div></dl>
|
|
121
|
-
<h2>Sections</h2>
|
|
122
|
-
<table><thead><tr><th>#</th><th>Section</th><th>Title</th><th>Status</th><th class="num">
|
|
123
|
-
$rows </tbody></table>
|
|
124
|
-
<h2>Drives</h2>
|
|
125
|
-
<table><thead><tr><th>Drive</th><th class="num">Size</th><th class="num">Free before</th><th class="num">Free after</th></tr></thead><tbody>
|
|
126
|
-
$drives </tbody></table>
|
|
127
|
-
<footer>Generated from <code>$(ConvertTo-HtmlText ([IO.Path]::GetFileName($Json)))</code> by <a href="$Script:WS_REPO">$Script:WS_NAME</a> v$(ConvertTo-HtmlText $r.meta.tool_version) - $(ConvertTo-HtmlText $r.credits.author.name)</footer>
|
|
128
|
-
</div></body></html>
|
|
129
|
-
"@
|
|
130
|
-
[IO.File]::WriteAllText($Out, $html, (New-Object System.Text.UTF8Encoding($false)))
|
|
131
|
-
Write-Ok "wrote $Out"
|
|
132
|
-
return $Out
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function Export-Reports {
|
|
136
|
-
<# .SYNOPSIS --export FMT ID. Returns an exit code. #>
|
|
137
|
-
param([string] $Format = 'both', [string] $Id = 'latest')
|
|
138
|
-
if ($Format -notin 'md', 'html', 'both') { Write-Err 'format must be md, html or both'; return $Script:WS_EXIT_USAGE }
|
|
139
|
-
$files = @(Get-ReportFiles)
|
|
140
|
-
if ($files.Count -eq 0) { Write-Err "no reports in $($Script:WS.ReportsDir)"; return $Script:WS_EXIT_FAIL }
|
|
141
|
-
$picked = @()
|
|
142
|
-
switch -Regex ($Id) {
|
|
143
|
-
'^all$' { $picked = $files }
|
|
144
|
-
'^latest$' { $picked = @($files[0]) }
|
|
145
|
-
'^\d+$' { $n = [int]$Id; if ($n -lt 1 -or $n -gt $files.Count) { Write-Err "index $Id out of range (1..$($files.Count))"; return $Script:WS_EXIT_USAGE }; $picked = @($files[$n - 1]) }
|
|
146
|
-
default { Write-Err 'ID must be a number, latest or all'; return $Script:WS_EXIT_USAGE }
|
|
147
|
-
}
|
|
148
|
-
foreach ($f in $picked) {
|
|
149
|
-
if ($Format -in 'md', 'both') { $null = Convert-ReportToMarkdown -Json $f.FullName }
|
|
150
|
-
if ($Format -in 'html', 'both') { $null = Convert-ReportToHtml -Json $f.FullName }
|
|
151
|
-
}
|
|
152
|
-
return $Script:WS_EXIT_OK
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function Invoke-ReportsManager {
|
|
156
|
-
Write-Box 'Reports manager' $Script:WS.ReportsDir
|
|
157
|
-
$files = @(Show-ReportList)
|
|
158
|
-
if ($files.Count -eq 0) { return }
|
|
159
|
-
if (-not $Script:WS.Interactive) { Write-Note 'non-interactive: use --export md|html|both N|latest|all'; return }
|
|
160
|
-
Write-UiLine '' 'Gray'
|
|
161
|
-
Write-Plain ' cm N markdown ch N html cb N both all export everything v N view JSON o N open HTML d N delete q quit'
|
|
162
|
-
$answer = Read-Line ' > '
|
|
163
|
-
if ($null -eq $answer) { return }
|
|
164
|
-
$parts = $answer.Trim() -split '\s+'
|
|
165
|
-
$cmd = $parts[0].ToLowerInvariant()
|
|
166
|
-
$n = 0
|
|
167
|
-
if ($parts.Count -gt 1 -and $parts[1] -match '^\d+$') { $n = [int]$parts[1] }
|
|
168
|
-
$target = $null
|
|
169
|
-
if ($n -ge 1 -and $n -le $files.Count) { $target = $files[$n - 1].FullName }
|
|
170
|
-
switch ($cmd) {
|
|
171
|
-
'cm' { if ($target) { $null = Convert-ReportToMarkdown -Json $target } else { Write-Err 'invalid index' } }
|
|
172
|
-
'ch' { if ($target) { $null = Convert-ReportToHtml -Json $target } else { Write-Err 'invalid index' } }
|
|
173
|
-
'cb' { if ($target) { $null = Convert-ReportToMarkdown -Json $target; $null = Convert-ReportToHtml -Json $target } else { Write-Err 'invalid index' } }
|
|
174
|
-
'all' { foreach ($f in $files) { $null = Convert-ReportToMarkdown -Json $f.FullName; $null = Convert-ReportToHtml -Json $f.FullName } }
|
|
175
|
-
'v' { if ($target) { Get-Content -LiteralPath $target | ForEach-Object { Write-Plain $_ } } else { Write-Err 'invalid index' } }
|
|
176
|
-
'o' {
|
|
177
|
-
if ($target) {
|
|
178
|
-
$html = [IO.Path]::ChangeExtension($target, '.html')
|
|
179
|
-
if (-not (Test-Path -LiteralPath $html)) { $null = Convert-ReportToHtml -Json $target }
|
|
180
|
-
try { Start-Process $html | Out-Null; Write-Ok "opened $html" } catch { Write-Warn "could not open $html" }
|
|
181
|
-
} else { Write-Err 'invalid index' }
|
|
182
|
-
}
|
|
183
|
-
'd' {
|
|
184
|
-
if ($target) {
|
|
185
|
-
if (Confirm-Ui -Prompt "Delete $([IO.Path]::GetFileName($target)) and its .md/.html siblings?" -Default 'n') {
|
|
186
|
-
foreach ($ext in '.json', '.md', '.html') { $p = [IO.Path]::ChangeExtension($target, $ext); if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue } }
|
|
187
|
-
Write-Ok 'deleted'
|
|
188
|
-
}
|
|
189
|
-
} else { Write-Err 'invalid index' }
|
|
190
|
-
}
|
|
191
|
-
'q' { return }
|
|
192
|
-
default { Write-Warn "unknown action: $cmd" }
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function Show-Stats {
|
|
197
|
-
Write-Box 'Run history' $Script:WS.Home
|
|
198
|
-
$files = @(Get-ReportFiles)
|
|
199
|
-
if ($files.Count -eq 0) { Write-Info 'no runs recorded yet'; return }
|
|
200
|
-
$total = [long]0; $runs = 0; $dry = 0
|
|
201
|
-
foreach ($f in $files) {
|
|
202
|
-
$r = Read-Report $f.FullName
|
|
203
|
-
if (-not $r) { continue }
|
|
204
|
-
if ($r.meta.dry_run) { $dry++ } else { $runs++; $total += [long]$r.totals.total_reclaimed_bytes }
|
|
205
|
-
}
|
|
206
|
-
Write-Kv 'Real runs:' $runs
|
|
207
|
-
Write-Kv 'Dry-runs:' $dry
|
|
208
|
-
Write-Kv 'Reclaimed overall:' (Format-Bytes $total)
|
|
209
|
-
Write-Kv 'Latest report:' $files[0].FullName
|
|
210
|
-
$logs = Join-Path $Script:WS.Home 'logs'
|
|
211
|
-
if (Test-Path -LiteralPath $logs) { Write-Kv 'Logs on disk:' (Format-Bytes (Get-DirectoryBytes $logs)) }
|
|
212
|
-
}
|
|
1
|
+
# reports.ps1 - list, view, export (Markdown / HTML) and summarise past JSON reports. No external tools needed.
|
|
2
|
+
|
|
3
|
+
function Get-ReportFiles {
|
|
4
|
+
$dir = $Script:WS.ReportsDir
|
|
5
|
+
if (-not (Test-Path -LiteralPath $dir)) { return @() }
|
|
6
|
+
return @(Get-ChildItem -LiteralPath $dir -Filter 'report-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name -Descending)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function Read-Report {
|
|
10
|
+
param([string] $Path)
|
|
11
|
+
try { return (Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop) } catch { return $null }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function Show-ReportList {
|
|
15
|
+
$files = @(Get-ReportFiles)
|
|
16
|
+
if ($files.Count -eq 0) { Write-Info 'no reports yet - windowsweep --scan writes one and deletes nothing'; return @() }
|
|
17
|
+
Write-UiLine (" {0,3} {1,-34} {2,8} {3,-17} {4,-12} {5}" -f '#', 'REPORT', 'SIZE', 'STARTED', 'RECLAIMED', 'MODE') 'White'
|
|
18
|
+
$i = 1
|
|
19
|
+
foreach ($f in $files) {
|
|
20
|
+
$r = Read-Report $f.FullName
|
|
21
|
+
$started = '?'; $rec = '?'; $mode = '?'
|
|
22
|
+
if ($r) {
|
|
23
|
+
$started = ([string]$r.meta.started_at).Substring(0, [math]::Min(16, ([string]$r.meta.started_at).Length)).Replace('T', ' ')
|
|
24
|
+
$rec = $r.totals.total_reclaimed_human
|
|
25
|
+
if ($r.meta.dry_run) { $rec = "~$($r.totals.total_estimated_human) (dry)" }
|
|
26
|
+
$mode = $r.meta.mode
|
|
27
|
+
}
|
|
28
|
+
Write-UiLine (" {0,3} {1,-34} {2,8} {3,-17} {4,-12} {5}" -f $i, $f.Name, (Format-Bytes $f.Length), $started, $rec, $mode) 'Gray'
|
|
29
|
+
$i++
|
|
30
|
+
}
|
|
31
|
+
return $files
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ConvertTo-HtmlText { param([string] $Text) return [System.Net.WebUtility]::HtmlEncode([string]$Text) }
|
|
35
|
+
|
|
36
|
+
function Convert-ReportToMarkdown {
|
|
37
|
+
param([string] $Json, [string] $Out = '')
|
|
38
|
+
$r = Read-Report $Json
|
|
39
|
+
if (-not $r) { Write-Err "cannot read $Json"; return $null }
|
|
40
|
+
if (-not $Out) { $Out = [IO.Path]::ChangeExtension($Json, '.md') }
|
|
41
|
+
$dry = 'no'
|
|
42
|
+
if ($r.meta.dry_run) { $dry = 'yes' }
|
|
43
|
+
$l = @()
|
|
44
|
+
$l += "# $Script:WS_NAME session report"; $l += ''
|
|
45
|
+
$l += "_Generated from ``$([IO.Path]::GetFileName($Json))``_"; $l += ''
|
|
46
|
+
$l += '## Run'; $l += ''; $l += '| Field | Value |'; $l += '|---|---|'
|
|
47
|
+
$l += "| Started | $($r.meta.started_at) |"; $l += "| Finished | $($r.meta.finished_at) |"; $l += "| Duration | $($r.meta.duration_seconds)s |"
|
|
48
|
+
$l += "| Host | $($r.meta.host) |"; $l += "| User | $($r.meta.user) |"; $l += "| Windows | $($r.meta.os) |"; $l += "| Mode | $($r.meta.mode) |"
|
|
49
|
+
$l += "| Dry-run | $dry |"; $l += "| Elevated | $($r.meta.elevated) |"; $l += "| Developer mode | $($r.meta.developer_mode) |"; $l += "| Idle window | $($r.meta.idle_days) days |"
|
|
50
|
+
$l += ''; $l += '## Result'; $l += ''
|
|
51
|
+
if ($r.meta.dry_run) { $l += "**Would reclaim (est.): $($r.totals.total_estimated_human)** " } else { $l += "**Reclaimed: $($r.totals.total_reclaimed_human)** ($($r.totals.total_reclaimed_bytes) bytes) " }
|
|
52
|
+
$l += "Sections run: $($r.totals.steps_run) - skipped: $($r.totals.steps_skipped)"; $l += ''
|
|
53
|
+
$l += '## Drives'; $l += ''; $l += '| Drive | Free before | Free after | Size |'; $l += '|---|---|---|---|'
|
|
54
|
+
foreach ($b in $r.disk.before) {
|
|
55
|
+
$a = $r.disk.after | Where-Object { $_.drive -eq $b.drive } | Select-Object -First 1
|
|
56
|
+
$afterFree = '?'
|
|
57
|
+
if ($a) { $afterFree = Format-Bytes $a.free_bytes }
|
|
58
|
+
$l += "| $($b.drive) | $(Format-Bytes $b.free_bytes) | $afterFree | $(Format-Bytes $b.size_bytes) |"
|
|
59
|
+
}
|
|
60
|
+
$l += ''; $l += '## Sections'; $l += ''; $l += '| # | Section | Title | Status | Reclaimed |'; $l += '|---:|---:|---|---|---:|'
|
|
61
|
+
foreach ($s in $r.steps) { $l += "| $($s.n) | $($s.section) | $($s.title) | $($s.status) | $(Format-Bytes $s.freed_bytes) |" }
|
|
62
|
+
$l += ''; $l += "_${Script:WS_NAME} v$($r.meta.tool_version) by $($r.credits.author.name) - $($r.credits.tool_homepage)_"
|
|
63
|
+
[IO.File]::WriteAllLines($Out, $l)
|
|
64
|
+
Write-Ok "wrote $Out"
|
|
65
|
+
return $Out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function Convert-ReportToHtml {
|
|
69
|
+
param([string] $Json, [string] $Out = '')
|
|
70
|
+
$r = Read-Report $Json
|
|
71
|
+
if (-not $r) { Write-Err "cannot read $Json"; return $null }
|
|
72
|
+
if (-not $Out) { $Out = [IO.Path]::ChangeExtension($Json, '.html') }
|
|
73
|
+
$headline = "Reclaimed"; $big = $r.totals.total_reclaimed_human
|
|
74
|
+
if ($r.meta.dry_run) { $headline = 'Would reclaim (est.)'; $big = $r.totals.total_estimated_human }
|
|
75
|
+
$rows = ''
|
|
76
|
+
foreach ($s in $r.steps) {
|
|
77
|
+
$cls = ([string]$s.status) -replace '[^a-z-]', ''
|
|
78
|
+
$rows += "<tr class=`"row-$cls`"><td>$($s.n)</td><td>$($s.section)</td><td>$(ConvertTo-HtmlText $s.title)</td><td><span class=`"badge $cls`">$(ConvertTo-HtmlText $s.status)</span></td><td class=`"num`">$(ConvertTo-HtmlText (Format-Bytes $s.freed_bytes))</td></tr>`n"
|
|
79
|
+
}
|
|
80
|
+
$drives = ''
|
|
81
|
+
foreach ($b in $r.disk.before) {
|
|
82
|
+
$a = $r.disk.after | Where-Object { $_.drive -eq $b.drive } | Select-Object -First 1
|
|
83
|
+
$afterFree = '?'
|
|
84
|
+
if ($a) { $afterFree = Format-Bytes $a.free_bytes }
|
|
85
|
+
$drives += "<tr><td>$(ConvertTo-HtmlText $b.drive)</td><td class=`"num`">$(Format-Bytes $b.size_bytes)</td><td class=`"num`">$(Format-Bytes $b.free_bytes)</td><td class=`"num`">$afterFree</td></tr>`n"
|
|
86
|
+
}
|
|
87
|
+
$html = @"
|
|
88
|
+
<!doctype html>
|
|
89
|
+
<html lang="en">
|
|
90
|
+
<head>
|
|
91
|
+
<meta charset="utf-8">
|
|
92
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
93
|
+
<title>$Script:WS_NAME session report - $(ConvertTo-HtmlText $r.meta.started_at)</title>
|
|
94
|
+
<style>
|
|
95
|
+
:root{--fg:#1c1c1f;--bg:#fafafa;--muted:#7a7a85;--ok:#0a7d54;--warn:#b8860b;--line:#e6e6ea;--card:#fff;--accent:#0b6bcb}
|
|
96
|
+
@media (prefers-color-scheme:dark){:root{--fg:#eaeaea;--bg:#16161a;--muted:#9d9da8;--line:#2a2a30;--card:#1f1f25;--accent:#5aa9ff}}
|
|
97
|
+
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.55 'Segoe UI',system-ui,sans-serif}
|
|
98
|
+
.wrap{max-width:960px;margin:2rem auto;padding:0 1.25rem}h1{font-size:1.6rem;margin:0 0 .3rem}
|
|
99
|
+
h2{font-size:1.05rem;margin:2.2rem 0 .7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
|
|
100
|
+
.subtitle{color:var(--muted);margin:0 0 1.5rem}.totals{display:flex;align-items:baseline;gap:1rem;padding:1.5rem 1.75rem;border:1px solid var(--line);border-radius:12px;background:var(--card);margin:1rem 0}
|
|
101
|
+
.totals .big{font-size:2.5rem;font-weight:800;color:var(--ok);line-height:1}.totals .label{color:var(--muted);text-transform:uppercase;font-size:.75rem;letter-spacing:.08em}
|
|
102
|
+
dl{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.6rem 1.5rem;margin:0;padding:1rem 1.25rem;border:1px solid var(--line);border-radius:10px;background:var(--card)}
|
|
103
|
+
dt{color:var(--muted);font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}dd{margin:.1rem 0 0;font-weight:600;word-break:break-all}
|
|
104
|
+
table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
|
105
|
+
th,td{text-align:left;padding:.55rem .85rem;border-bottom:1px solid var(--line)}tr:last-child td{border-bottom:none}
|
|
106
|
+
th{font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;background:var(--bg)}td.num{text-align:right;font-variant-numeric:tabular-nums}
|
|
107
|
+
.badge{display:inline-block;padding:.12em .5em;border-radius:999px;font-size:.75em;font-weight:600;text-transform:uppercase}
|
|
108
|
+
.badge.ran,.badge.dry-run{background:rgba(10,125,84,.12);color:var(--ok)}.badge.skipped,.badge.refused{background:rgba(184,134,11,.12);color:var(--warn)}.badge.failed{background:rgba(192,57,43,.12);color:#c0392b}
|
|
109
|
+
footer{margin:3rem 0 1rem;color:var(--muted);font-size:.85rem;text-align:center}a{color:var(--accent)}
|
|
110
|
+
</style>
|
|
111
|
+
</head>
|
|
112
|
+
<body><div class="wrap">
|
|
113
|
+
<h1>$Script:WS_NAME session report</h1>
|
|
114
|
+
<p class="subtitle">$(ConvertTo-HtmlText $r.meta.started_at) to $(ConvertTo-HtmlText $r.meta.finished_at) - $($r.meta.duration_seconds)s - $(ConvertTo-HtmlText $r.meta.host)</p>
|
|
115
|
+
<div class="totals"><div><div class="label">$headline</div><div class="big">$(ConvertTo-HtmlText $big)</div></div>
|
|
116
|
+
<div style="margin-left:auto;text-align:right;color:var(--muted)"><div><strong>$($r.totals.steps_run)</strong> sections run</div><div><strong>$($r.totals.steps_skipped)</strong> skipped</div></div></div>
|
|
117
|
+
<h2>Run</h2>
|
|
118
|
+
<dl><div><dt>Mode</dt><dd>$(ConvertTo-HtmlText $r.meta.mode)</dd></div><div><dt>Dry-run</dt><dd>$($r.meta.dry_run)</dd></div><div><dt>Elevated</dt><dd>$($r.meta.elevated)</dd></div>
|
|
119
|
+
<div><dt>Developer mode</dt><dd>$($r.meta.developer_mode)</dd></div><div><dt>Idle window</dt><dd>$($r.meta.idle_days) days</dd></div><div><dt>Windows</dt><dd>$(ConvertTo-HtmlText $r.meta.os)</dd></div>
|
|
120
|
+
<div><dt>Log file</dt><dd><code>$(ConvertTo-HtmlText $r.meta.log_file)</code></dd></div></dl>
|
|
121
|
+
<h2>Sections</h2>
|
|
122
|
+
<table><thead><tr><th>#</th><th>Section</th><th>Title</th><th>Status</th><th class="num">Reclaimed</th></tr></thead><tbody>
|
|
123
|
+
$rows </tbody></table>
|
|
124
|
+
<h2>Drives</h2>
|
|
125
|
+
<table><thead><tr><th>Drive</th><th class="num">Size</th><th class="num">Free before</th><th class="num">Free after</th></tr></thead><tbody>
|
|
126
|
+
$drives </tbody></table>
|
|
127
|
+
<footer>Generated from <code>$(ConvertTo-HtmlText ([IO.Path]::GetFileName($Json)))</code> by <a href="$Script:WS_REPO">$Script:WS_NAME</a> v$(ConvertTo-HtmlText $r.meta.tool_version) - $(ConvertTo-HtmlText $r.credits.author.name)</footer>
|
|
128
|
+
</div></body></html>
|
|
129
|
+
"@
|
|
130
|
+
[IO.File]::WriteAllText($Out, $html, (New-Object System.Text.UTF8Encoding($false)))
|
|
131
|
+
Write-Ok "wrote $Out"
|
|
132
|
+
return $Out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function Export-Reports {
|
|
136
|
+
<# .SYNOPSIS --export FMT ID. Returns an exit code. #>
|
|
137
|
+
param([string] $Format = 'both', [string] $Id = 'latest')
|
|
138
|
+
if ($Format -notin 'md', 'html', 'both') { Write-Err 'format must be md, html or both'; return $Script:WS_EXIT_USAGE }
|
|
139
|
+
$files = @(Get-ReportFiles)
|
|
140
|
+
if ($files.Count -eq 0) { Write-Err "no reports in $($Script:WS.ReportsDir)"; return $Script:WS_EXIT_FAIL }
|
|
141
|
+
$picked = @()
|
|
142
|
+
switch -Regex ($Id) {
|
|
143
|
+
'^all$' { $picked = $files }
|
|
144
|
+
'^latest$' { $picked = @($files[0]) }
|
|
145
|
+
'^\d+$' { $n = [int]$Id; if ($n -lt 1 -or $n -gt $files.Count) { Write-Err "index $Id out of range (1..$($files.Count))"; return $Script:WS_EXIT_USAGE }; $picked = @($files[$n - 1]) }
|
|
146
|
+
default { Write-Err 'ID must be a number, latest or all'; return $Script:WS_EXIT_USAGE }
|
|
147
|
+
}
|
|
148
|
+
foreach ($f in $picked) {
|
|
149
|
+
if ($Format -in 'md', 'both') { $null = Convert-ReportToMarkdown -Json $f.FullName }
|
|
150
|
+
if ($Format -in 'html', 'both') { $null = Convert-ReportToHtml -Json $f.FullName }
|
|
151
|
+
}
|
|
152
|
+
return $Script:WS_EXIT_OK
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function Invoke-ReportsManager {
|
|
156
|
+
Write-Box 'Reports manager' $Script:WS.ReportsDir
|
|
157
|
+
$files = @(Show-ReportList)
|
|
158
|
+
if ($files.Count -eq 0) { return }
|
|
159
|
+
if (-not $Script:WS.Interactive) { Write-Note 'non-interactive: use --export md|html|both N|latest|all'; return }
|
|
160
|
+
Write-UiLine '' 'Gray'
|
|
161
|
+
Write-Plain ' cm N markdown ch N html cb N both all export everything v N view JSON o N open HTML d N delete q quit'
|
|
162
|
+
$answer = Read-Line ' > '
|
|
163
|
+
if ($null -eq $answer) { return }
|
|
164
|
+
$parts = $answer.Trim() -split '\s+'
|
|
165
|
+
$cmd = $parts[0].ToLowerInvariant()
|
|
166
|
+
$n = 0
|
|
167
|
+
if ($parts.Count -gt 1 -and $parts[1] -match '^\d+$') { $n = [int]$parts[1] }
|
|
168
|
+
$target = $null
|
|
169
|
+
if ($n -ge 1 -and $n -le $files.Count) { $target = $files[$n - 1].FullName }
|
|
170
|
+
switch ($cmd) {
|
|
171
|
+
'cm' { if ($target) { $null = Convert-ReportToMarkdown -Json $target } else { Write-Err 'invalid index' } }
|
|
172
|
+
'ch' { if ($target) { $null = Convert-ReportToHtml -Json $target } else { Write-Err 'invalid index' } }
|
|
173
|
+
'cb' { if ($target) { $null = Convert-ReportToMarkdown -Json $target; $null = Convert-ReportToHtml -Json $target } else { Write-Err 'invalid index' } }
|
|
174
|
+
'all' { foreach ($f in $files) { $null = Convert-ReportToMarkdown -Json $f.FullName; $null = Convert-ReportToHtml -Json $f.FullName } }
|
|
175
|
+
'v' { if ($target) { Get-Content -LiteralPath $target | ForEach-Object { Write-Plain $_ } } else { Write-Err 'invalid index' } }
|
|
176
|
+
'o' {
|
|
177
|
+
if ($target) {
|
|
178
|
+
$html = [IO.Path]::ChangeExtension($target, '.html')
|
|
179
|
+
if (-not (Test-Path -LiteralPath $html)) { $null = Convert-ReportToHtml -Json $target }
|
|
180
|
+
try { Start-Process $html | Out-Null; Write-Ok "opened $html" } catch { Write-Warn "could not open $html" }
|
|
181
|
+
} else { Write-Err 'invalid index' }
|
|
182
|
+
}
|
|
183
|
+
'd' {
|
|
184
|
+
if ($target) {
|
|
185
|
+
if (Confirm-Ui -Prompt "Delete $([IO.Path]::GetFileName($target)) and its .md/.html siblings?" -Default 'n') {
|
|
186
|
+
foreach ($ext in '.json', '.md', '.html') { $p = [IO.Path]::ChangeExtension($target, $ext); if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue } }
|
|
187
|
+
Write-Ok 'deleted'
|
|
188
|
+
}
|
|
189
|
+
} else { Write-Err 'invalid index' }
|
|
190
|
+
}
|
|
191
|
+
'q' { return }
|
|
192
|
+
default { Write-Warn "unknown action: $cmd" }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function Show-Stats {
|
|
197
|
+
Write-Box 'Run history' $Script:WS.Home
|
|
198
|
+
$files = @(Get-ReportFiles)
|
|
199
|
+
if ($files.Count -eq 0) { Write-Info 'no runs recorded yet'; return }
|
|
200
|
+
$total = [long]0; $runs = 0; $dry = 0
|
|
201
|
+
foreach ($f in $files) {
|
|
202
|
+
$r = Read-Report $f.FullName
|
|
203
|
+
if (-not $r) { continue }
|
|
204
|
+
if ($r.meta.dry_run) { $dry++ } else { $runs++; $total += [long]$r.totals.total_reclaimed_bytes }
|
|
205
|
+
}
|
|
206
|
+
Write-Kv 'Real runs:' $runs
|
|
207
|
+
Write-Kv 'Dry-runs:' $dry
|
|
208
|
+
Write-Kv 'Reclaimed overall:' (Format-Bytes $total)
|
|
209
|
+
Write-Kv 'Latest report:' $files[0].FullName
|
|
210
|
+
$logs = Join-Path $Script:WS.Home 'logs'
|
|
211
|
+
if (Test-Path -LiteralPath $logs) { Write-Kv 'Logs on disk:' (Format-Bytes (Get-DirectoryBytes $logs)) }
|
|
212
|
+
}
|