windowsweep 1.0.1 → 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.
Files changed (41) hide show
  1. package/AI-INTEGRATION-GUIDE.md +203 -0
  2. package/CHANGELOG.md +124 -0
  3. package/LICENSE +21 -21
  4. package/README.md +88 -49
  5. package/VERSION +1 -1
  6. package/lib/actions.ps1 +212 -209
  7. package/lib/config.ps1 +108 -99
  8. package/lib/constants.ps1 +103 -89
  9. package/lib/fs.ps1 +189 -180
  10. package/lib/log.ps1 +140 -140
  11. package/lib/safety.ps1 +541 -493
  12. package/lib/scan.ps1 +109 -94
  13. package/lib/ui.ps1 +275 -235
  14. package/modules/android_avd.ps1 +36 -36
  15. package/modules/app_caches.ps1 +58 -58
  16. package/modules/browsers.ps1 +33 -33
  17. package/modules/build_tools.ps1 +35 -35
  18. package/modules/crash_trap.ps1 +89 -89
  19. package/modules/disk_usage.ps1 +49 -49
  20. package/modules/docker.ps1 +93 -93
  21. package/modules/editors.ps1 +150 -150
  22. package/modules/globals_audit.ps1 +236 -0
  23. package/modules/health.ps1 +101 -101
  24. package/modules/installed_programs.ps1 +118 -0
  25. package/modules/menu.ps1 +63 -61
  26. package/modules/notify.ps1 +60 -0
  27. package/modules/orphaned_appdata.ps1 +189 -0
  28. package/modules/personal.ps1 +126 -124
  29. package/modules/pkg_managers.ps1 +73 -69
  30. package/modules/projects.ps1 +160 -143
  31. package/modules/release_helpers.ps1 +438 -434
  32. package/modules/reports.ps1 +212 -212
  33. package/modules/runner.ps1 +242 -190
  34. package/modules/self_test_extra.ps1 +537 -86
  35. package/modules/startup_audit.ps1 +126 -0
  36. package/modules/system_admin.ps1 +220 -220
  37. package/modules/test_runners.ps1 +24 -24
  38. package/modules/walkthrough.ps1 +80 -79
  39. package/modules/windows_user.ps1 +118 -112
  40. package/package.json +4 -3
  41. package/windowsweep.ps1 +322 -296
@@ -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 - run a cleanup first'; 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 += '## Overview'; $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 free (estimate): $($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/refused: $($r.totals.steps_skipped)"; $l += ''
53
- $l += '## Drives'; $l += ''; $l += '| Drive | Before free | After free | 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 += '## Steps'; $l += ''; $l += '| # | Section | Title | Status | Freed |'; $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 free (dry-run estimate)'; $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 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">Freed</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
- }
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
+ }