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
package/modules/menu.ps1 CHANGED
@@ -1,61 +1,63 @@
1
- # menu.ps1 - jump-to menu: run one section at a time; toggle dry-run and auto-yes.
2
-
3
- function Show-Menu {
4
- $ws = $Script:WS
5
- Write-UiLine '' 'Gray'
6
- Write-Banner
7
- $sys = New-Object IO.DriveInfo($Script:P.SD)
8
- $dry = 'OFF'
9
- if ($ws.DryRun) { $dry = 'ON' }
10
- $yes = 'OFF'
11
- if ($ws.Yes) { $yes = 'ON' }
12
- $adm = 'no'
13
- if ($ws.IsAdmin) { $adm = 'yes' }
14
- Write-UiLine (" system drive free: {0} dry-run: {1} auto-yes: {2} elevated: {3}" -f (Format-Bytes $sys.AvailableFreeSpace), $dry, $yes, $adm) 'Cyan'
15
- Write-UiLine '' 'Gray'
16
- foreach ($s in $Script:WS_SECTIONS) {
17
- $mark = ' '
18
- if ($ws.Report.steps | Where-Object { $_.section -eq $s.Id -and $_.status -in 'ran', 'dry-run' }) { $mark = $ws.Glyph.ok }
19
- $flags = ''
20
- if ($s.Admin) { $flags += ' [admin]' }
21
- if ($s.Batch -eq 'deep') { $flags += ' [deep]' }
22
- if ($s.Batch -eq 'interactive') { $flags += ' [interactive]' }
23
- $color = 'Gray'
24
- if ($s.Admin -and -not $ws.IsAdmin) { $color = 'DarkGray' }
25
- Write-UiLine (" {0} [{1,2}] {2}{3}" -f $mark, $s.Id, $s.Title, $flags) $color
26
- }
27
- Write-UiLine '' 'Gray'
28
- Write-UiLine ' [A] run the safe batch [D] toggle dry-run [Y] toggle auto-yes [S] summary [Q] quit' 'White'
29
- }
30
-
31
- function Invoke-Menu {
32
- $ws = $Script:WS
33
- if (-not $ws.Interactive) {
34
- Write-Err 'The menu needs an interactive console. For unattended runs use: windowsweep --all --yes'
35
- $ws.ExitCode = $Script:WS_EXIT_USAGE
36
- return
37
- }
38
- while ($true) {
39
- Show-Menu
40
- $choice = Read-Choice -Prompt ' Select [0-21 / A / D / Y / S / Q]: ' -Default ''
41
- if ($choice -eq '') { continue }
42
- switch -Regex ($choice) {
43
- '^q' { Show-SessionSummary; return }
44
- '^a' {
45
- $ids = @($Script:WS_SAFE_BATCH)
46
- if ($ws.IsAdmin) { $ids += $Script:WS_SAFE_BATCH_ADMIN }
47
- $ws.BatchMode = $true
48
- try { foreach ($id in $ids) { Invoke-SectionById -Id $id } } finally { $ws.BatchMode = $false }
49
- Wait-Enter
50
- }
51
- '^d' { $ws.DryRun = -not $ws.DryRun }
52
- '^y' { $ws.Yes = -not $ws.Yes }
53
- '^s' { Show-SessionSummary; Wait-Enter }
54
- '^\d+$' {
55
- $id = [int]$choice
56
- if (Get-Section $id) { Invoke-SectionById -Id $id; Wait-Enter } else { Write-Warn "no section $id" }
57
- }
58
- default { Write-Warn "unrecognised choice: $choice" }
59
- }
60
- }
61
- }
1
+ # menu.ps1 - jump-to menu: run one section at a time; toggle dry-run and auto-yes.
2
+
3
+ function Show-Menu {
4
+ $ws = $Script:WS
5
+ Write-UiLine '' 'Gray'
6
+ Write-Banner
7
+ $sys = New-Object IO.DriveInfo($Script:P.SD)
8
+ $dry = 'OFF'
9
+ if ($ws.DryRun) { $dry = 'ON' }
10
+ $yes = 'OFF'
11
+ if ($ws.Yes) { $yes = 'ON' }
12
+ $adm = 'no'
13
+ if ($ws.IsAdmin) { $adm = 'yes' }
14
+ Write-UiLine (" system drive free: {0} dry-run: {1} auto-yes: {2} elevated: {3}" -f (Format-Bytes $sys.AvailableFreeSpace), $dry, $yes, $adm) 'Cyan'
15
+ Write-UiLine '' 'Gray'
16
+ foreach ($s in $Script:WS_SECTIONS) {
17
+ $mark = ' '
18
+ if ($ws.Report.steps | Where-Object { $_.section -eq $s.Id -and $_.status -in 'ran', 'dry-run' }) { $mark = $ws.Glyph.ok }
19
+ $flags = ''
20
+ if ($s.Admin) { $flags += ' [admin]' }
21
+ if ($s.Batch -eq 'deep') { $flags += ' [deep]' }
22
+ if ($s.Batch -eq 'interactive') { $flags += ' [interactive]' }
23
+ $color = 'Gray'
24
+ if ($s.Admin -and -not $ws.IsAdmin) { $color = 'DarkGray' }
25
+ Write-UiLine (" {0} [{1,2}] {2}{3}" -f $mark, $s.Id, $s.Title, $flags) $color
26
+ }
27
+ Write-UiLine '' 'Gray'
28
+ Write-UiLine ' [A] run the safe batch [D] toggle dry-run [Y] toggle auto-yes [S] summary [Q] quit' 'White'
29
+ }
30
+
31
+ function Invoke-Menu {
32
+ $ws = $Script:WS
33
+ if (-not $ws.Interactive) {
34
+ Write-Err 'The menu needs an interactive console. For unattended runs use: windowsweep --all --yes'
35
+ $ws.ExitCode = $Script:WS_EXIT_USAGE
36
+ return
37
+ }
38
+ while ($true) {
39
+ Show-Menu
40
+ $lo = ($Script:WS_SECTIONS | Measure-Object Id -Minimum).Minimum
41
+ $hi = ($Script:WS_SECTIONS | Measure-Object Id -Maximum).Maximum
42
+ $choice = Read-Choice -Prompt " Select [$lo-$hi / A / D / Y / S / Q]: " -Default ''
43
+ if ($choice -eq '') { continue }
44
+ switch -Regex ($choice) {
45
+ '^q' { Show-SessionSummary; return }
46
+ '^a' {
47
+ $ids = @($Script:WS_SAFE_BATCH)
48
+ if ($ws.IsAdmin) { $ids += $Script:WS_SAFE_BATCH_ADMIN }
49
+ $ws.BatchMode = $true
50
+ try { foreach ($id in $ids) { Invoke-SectionById -Id $id } } finally { $ws.BatchMode = $false }
51
+ Wait-Enter
52
+ }
53
+ '^d' { $ws.DryRun = -not $ws.DryRun }
54
+ '^y' { $ws.Yes = -not $ws.Yes }
55
+ '^s' { Show-SessionSummary; Wait-Enter }
56
+ '^\d+$' {
57
+ $id = [int]$choice
58
+ if (Get-Section $id) { Invoke-SectionById -Id $id; Wait-Enter } else { Write-Warn "no section $id" }
59
+ }
60
+ default { Write-Warn "unrecognised choice: $choice" }
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,60 @@
1
+ # notify.ps1 - --notify: one Windows notification when a run ends.
2
+ #
3
+ # Off by default. --quiet still notifies (that is the point of the flag when a Scheduled Task runs it).
4
+ # Every path is wrapped: a notification that cannot be shown is not a failed run, so this NEVER touches the
5
+ # exit code and NEVER writes to stdout - --json promises exactly one line there.
6
+
7
+ function Get-RunNotificationText {
8
+ <# .SYNOPSIS The two lines a notification carries. Pure, so the self-test can read it without a toast. #>
9
+ $ws = $Script:WS
10
+ $ran = @($ws.Report.steps | Where-Object { $_.status -in 'ran', 'dry-run' }).Count
11
+ if ($ws.DryRun) {
12
+ return [pscustomobject]@{ Title = "$Script:WS_NAME - dry run finished"; Body = ("about " + (Format-Bytes $ws.TotalEstimated) + " could be freed across $ran section(s)") }
13
+ }
14
+ return [pscustomobject]@{ Title = "$Script:WS_NAME finished"; Body = ("freed " + (Format-Bytes $ws.TotalFreed) + " across $ran section(s)") }
15
+ }
16
+
17
+ function Show-ToastNotification {
18
+ <# .SYNOPSIS Windows PowerShell 5.1: a real toast through WinRT, under the PowerShell AppUserModelId. #>
19
+ param([string] $Title, [string] $Body)
20
+ $appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
21
+ $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
22
+ $null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
23
+ $tpl = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
24
+ $texts = $tpl.GetElementsByTagName('text')
25
+ $null = $texts.Item(0).AppendChild($tpl.CreateTextNode($Title))
26
+ $null = $texts.Item(1).AppendChild($tpl.CreateTextNode($Body))
27
+ $toast = New-Object Windows.UI.Notifications.ToastNotification($tpl)
28
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
29
+ }
30
+
31
+ function Show-BalloonNotification {
32
+ <# .SYNOPSIS PowerShell 7 has no WinRT projection by default: a tray balloon instead. #>
33
+ param([string] $Title, [string] $Body)
34
+ Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
35
+ Add-Type -AssemblyName System.Drawing -ErrorAction Stop
36
+ $icon = New-Object System.Windows.Forms.NotifyIcon
37
+ try {
38
+ $icon.Icon = [System.Drawing.SystemIcons]::Information
39
+ $icon.Visible = $true
40
+ $icon.ShowBalloonTip(5000, $Title, $Body, [System.Windows.Forms.ToolTipIcon]::Info)
41
+ # The balloon needs the tray icon to survive a moment; without this it is created and destroyed unseen.
42
+ Start-Sleep -Milliseconds 600
43
+ } finally {
44
+ $icon.Visible = $false
45
+ $icon.Dispose()
46
+ }
47
+ }
48
+
49
+ function Send-RunNotification {
50
+ <# .SYNOPSIS --notify: show the end-of-run notification, or say quietly in the log why it could not. #>
51
+ if (-not $Script:WS.Notify) { return }
52
+ $text = Get-RunNotificationText
53
+ try {
54
+ if ($PSVersionTable.PSEdition -eq 'Core') { Show-BalloonNotification -Title $text.Title -Body $text.Body }
55
+ else { Show-ToastNotification -Title $text.Title -Body $text.Body }
56
+ Write-LogLine "notification shown: $($text.Title) - $($text.Body)"
57
+ } catch {
58
+ Write-LogLine "notification could not be shown (this never affects the exit code): $($_.Exception.Message)"
59
+ }
60
+ }
@@ -0,0 +1,189 @@
1
+ # orphaned_appdata.ps1 - section 23: folders directly under %APPDATA% and %LOCALAPPDATA% that belong to no
2
+ # installed program. Interactive only; selected folders go to the Recycle Bin.
3
+ #
4
+ # The dangerous direction here is calling something orphaned when it is not, so every test errs the other
5
+ # way: matching is generous, the exclusion list is long, and an EMPTY program index produces ZERO candidates
6
+ # rather than "everything is orphaned".
7
+
8
+ # Windows' own folders, shared infrastructure, and vendors whose data outlives an uninstall.
9
+ $Script:WS_ORPHAN_EXCLUDE = @(
10
+ 'Microsoft', 'Windows', 'WindowsPowerShell', 'PowerShell', 'Packages', 'Programs', 'Temp', 'Tmp', 'Comms',
11
+ 'ConnectedDevicesPlatform', 'D3DSCache', 'Google', 'Mozilla', 'CrashDumps', 'VirtualStore', 'PeerDistRepub',
12
+ 'Publishers', 'History', 'IconCache', 'ElevatedDiagnostics', 'Application Data', 'Local Settings',
13
+ 'Deployment', 'assembly', 'GroupPolicy', 'Roaming', 'Local', 'LocalLow', 'Low', 'Diagnostics',
14
+ 'PlaceholderTileLogoFolder', 'Desktop', 'Downloads', 'Documents', 'Start Menu', 'SendTo',
15
+ 'Recent', 'NetHood', 'PrintHood', 'Templates', 'Cookies',
16
+ # Platform folders that belong to Windows or .NET rather than to any one program: they look orphaned
17
+ # because no uninstall entry names them, and one of them holds settings a .NET app wrote.
18
+ 'IsolatedStorage', 'ToastNotificationManagerCompat', 'AppV', 'Package Cache', 'DBG', 'CLR_v4.0', 'CLR_v4.0_32'
19
+ )
20
+
21
+ function ConvertTo-MatchToken {
22
+ <# .SYNOPSIS Lower-case, alphanumerics only - so "Slack Technologies, Inc." and "slack" compare equal. #>
23
+ param([string] $Text)
24
+ if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
25
+ return ($Text.ToLowerInvariant() -replace '[^a-z0-9]', '')
26
+ }
27
+
28
+ function Add-MatchTokens {
29
+ <# .SYNOPSIS Add a display string to the token set: the whole thing, plus each word of 4+ characters. #>
30
+ param([System.Collections.Generic.HashSet[string]] $Set, [string] $Text)
31
+ $whole = ConvertTo-MatchToken $Text
32
+ if ($whole.Length -ge 3) { $null = $Set.Add($whole) }
33
+ foreach ($w in ($Text -split '[^A-Za-z0-9]+')) {
34
+ $t = ConvertTo-MatchToken $w
35
+ if ($t.Length -ge 4) { $null = $Set.Add($t) }
36
+ }
37
+ }
38
+
39
+ function Get-InstalledProgramRows {
40
+ <# .SYNOPSIS Raw uninstall-hive rows from all three registry views. Shared with section 24. #>
41
+ $rows = @()
42
+ $hives = @(
43
+ 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
44
+ 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall',
45
+ 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
46
+ )
47
+ foreach ($h in $hives) {
48
+ $keys = @()
49
+ try { $keys = @(Get-ChildItem -LiteralPath $h -ErrorAction Stop) } catch { $keys = @() }
50
+ foreach ($k in $keys) {
51
+ $props = $null
52
+ try { $props = Get-ItemProperty -LiteralPath $k.PSPath -ErrorAction Stop } catch { continue }
53
+ if (-not $props.DisplayName) { continue }
54
+ $rows += [pscustomobject]@{
55
+ DisplayName = [string]$props.DisplayName; DisplayVersion = [string]$props.DisplayVersion
56
+ Publisher = [string]$props.Publisher; InstallDate = [string]$props.InstallDate
57
+ EstimatedSizeKb = [long]0 + $props.EstimatedSize; InstallLocation = [string]$props.InstallLocation
58
+ UninstallString = [string]$props.UninstallString; SystemComponent = [int]0 + $props.SystemComponent
59
+ }
60
+ }
61
+ }
62
+ return $rows
63
+ }
64
+
65
+ function Get-InstalledProgramIndex {
66
+ <# .SYNOPSIS Everything that says "a program owns this name": uninstall entries, program folders, Store
67
+ package names and running processes. RegistryCount drives the fail-closed gate in section 23. #>
68
+ $P = $Script:P
69
+ $set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
70
+ $rows = @(Get-InstalledProgramRows)
71
+ foreach ($r in $rows) {
72
+ Add-MatchTokens -Set $set -Text $r.DisplayName
73
+ Add-MatchTokens -Set $set -Text $r.Publisher
74
+ if ($r.InstallLocation) { Add-MatchTokens -Set $set -Text (Split-Path -Leaf ($r.InstallLocation.TrimEnd('\'))) }
75
+ }
76
+ foreach ($dir in @($P.PF, $P.PF86, (Join-Path $P.L 'Programs'), (Join-Path $P.L 'Microsoft\WindowsApps'), (Join-Path $P.PD 'Microsoft\Windows\Start Menu\Programs'))) {
77
+ if (-not $dir -or -not (Test-DirPresent $dir)) { continue }
78
+ foreach ($e in (Get-ChildEntries $dir)) { Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0]) }
79
+ }
80
+ foreach ($e in (Get-ChildEntries (Join-Path $P.L 'Packages'))) { Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0]) }
81
+ try { foreach ($proc in (Get-Process -ErrorAction SilentlyContinue)) { Add-MatchTokens -Set $set -Text $proc.ProcessName } } catch { $null = $_ }
82
+ return [pscustomobject]@{ Tokens = $set; RegistryCount = $rows.Count; Rows = $rows }
83
+ }
84
+
85
+ function Get-OrphanExclusions {
86
+ <# .SYNOPSIS Folder names section 23 never offers: the fixed list plus, derived at run time, the first
87
+ segment under AppData of every target the tool already declares - so a vendor folder we clean can never
88
+ also be offered for deletion, and the list cannot drift as sections are added. #>
89
+ $P = $Script:P
90
+ $set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
91
+ foreach ($n in $Script:WS_ORPHAN_EXCLUDE) { $null = $set.Add($n) }
92
+ $prefixes = @("$($P.A)\", "$($P.L)\")
93
+ foreach ($t in (Get-AllTargets)) {
94
+ $path = [string]$t.Path
95
+ foreach ($pre in $prefixes) {
96
+ if ($path.StartsWith($pre, [StringComparison]::OrdinalIgnoreCase)) {
97
+ $seg = ($path.Substring($pre.Length) -split '\\')[0]
98
+ if ($seg) { $null = $set.Add($seg) }
99
+ }
100
+ }
101
+ }
102
+ # ,$set - a bare `return $set` UNROLLS the HashSet into an Object[], whose .Contains is case-SENSITIVE,
103
+ # so a folder named "slack" would slip past the "Slack" entry. The comma keeps the set a set.
104
+ return , $set
105
+ }
106
+
107
+ function Test-OrphanFolderName {
108
+ <# .SYNOPSIS True when NOTHING installed claims this folder name. Deliberately generous in the other
109
+ direction: a token that merely starts with the name (or the reverse) counts as a claim. #>
110
+ param([string] $Name, [pscustomobject] $Index)
111
+ $n = ConvertTo-MatchToken $Name
112
+ if ($n.Length -lt 3) { return $false }
113
+ if ($Index.Tokens.Contains($n)) { return $false }
114
+ foreach ($t in $Index.Tokens) {
115
+ if ($t.Length -lt 4) { continue }
116
+ if ($t.StartsWith($n) -or $n.StartsWith($t)) { return $false }
117
+ }
118
+ return $true
119
+ }
120
+
121
+ function Find-OrphanedAppData {
122
+ <# .SYNOPSIS Top-level AppData folders idle N+ days that no installed program claims. Fails closed.
123
+ -Index is for the self-test: it proves the fail-closed gate without touching the real registry. #>
124
+ param([int] $Days, [pscustomobject] $Index = $null)
125
+ $P = $Script:P
126
+ $index = $Index
127
+ if ($null -eq $index) { $index = Get-InstalledProgramIndex }
128
+ if ($index.RegistryCount -eq 0) {
129
+ Write-Warn 'no uninstall entries could be read: refusing to call anything orphaned (fail closed).'
130
+ return @()
131
+ }
132
+ Write-Note "cross-checked against $($index.RegistryCount) installed-program entries"
133
+ $exclude = Get-OrphanExclusions
134
+ $out = @()
135
+ foreach ($root in @($P.A, $P.L)) {
136
+ if (-not (Test-DirPresent $root)) { continue }
137
+ foreach ($e in (Get-ChildEntries $root)) {
138
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
139
+ if ($exclude.Contains($e.Name)) { continue }
140
+ $full = Remove-LongPrefix $e.FullName
141
+ if (Get-ProtectionReason $full) { continue }
142
+ if (-not (Test-OrphanFolderName -Name $e.Name -Index $index)) { continue }
143
+ $idle = Get-IdleDays $full
144
+ if ($idle -lt $Days) { continue }
145
+ $out += [pscustomobject]@{ Name = $e.Name; Path = $full; Root = (Get-FullPath $root); Idle = $idle; Bytes = [long](Get-DirectoryBytes $full) }
146
+ }
147
+ }
148
+ return @($out | Sort-Object Bytes -Descending | Select-Object -First 200)
149
+ }
150
+
151
+ function Get-Targets23 {
152
+ <# .SYNOPSIS Informational rows: the candidates are found at run time, never declared as fixed paths. #>
153
+ $P = $Script:P
154
+ $t = @()
155
+ foreach ($r in @($P.A, $P.L)) {
156
+ if (Test-DirPresent $r) { $t += (New-Target 23 'AppData root scanned for orphans' (Get-FullPath $r) -Kind cmd -Note 'top-level folders only; you pick what goes, and it lands in the Recycle Bin') }
157
+ }
158
+ return $t
159
+ }
160
+
161
+ function Invoke-Section23 {
162
+ $ws = $Script:WS
163
+ Write-SectionIntro @(
164
+ 'Uninstalling a program usually leaves its AppData folder behind. This lists top-level folders under',
165
+ "Roaming and Local that no installed program, Store package or running process claims and that nothing",
166
+ "has touched for $($ws.Days)+ days. You pick what goes; it lands in the Recycle Bin."
167
+ )
168
+ $rows = @(Find-OrphanedAppData -Days $ws.Days)
169
+ if ($rows.Count -eq 0) { Write-Info 'no orphaned application data found'; return }
170
+ Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'FOLDER') 'White'
171
+ $i = 1; $total = [long]0
172
+ foreach ($r in $rows) {
173
+ Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $r.Bytes), $r.Idle, $r.Path) 'Gray'
174
+ Add-JsonCandidate -Section 23 -Index $i -Path $r.Path -Bytes $r.Bytes -IdleDays $r.Idle
175
+ $total += $r.Bytes; $i++
176
+ }
177
+ Write-Info ('total: ' + (Format-Bytes $total))
178
+ Write-Note 'a folder here can still hold settings or a licence for a program you plan to reinstall - read the list.'
179
+ $dest = 'the Recycle Bin'
180
+ if ($ws.Permanent) { $dest = 'PERMANENT deletion (--permanent)' }
181
+ Write-Note "selected folders go to $dest"
182
+ $picks = @(Read-MultiSelect -Total $rows.Count -NoAutoYes -Candidates @($rows | ForEach-Object { $_.Path }))
183
+ if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
184
+ if (-not $ws.DryRun) {
185
+ # Orphaned app data never auto-confirms: --yes does not apply. An explicit --select / --select-file does.
186
+ if (-not (Confirm-Ui -Prompt "Move $($picks.Count) folder(s) to $dest ?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
187
+ }
188
+ foreach ($k in $picks) { $null = Send-ToRecycleBin -Path $rows[$k - 1].Path -Within $rows[$k - 1].Root }
189
+ }
@@ -1,124 +1,126 @@
1
- # personal.ps1 - sections 18 and 19: personal files in Downloads. Interactive only; the Recycle Bin is the destination.
2
-
3
- $Script:WS_PARTIAL_PATTERNS = @('*.crdownload', '*.part', '*.partial', '*.fdmdownload', '*.opdownload', '*.!ut', '*.aria2', '*.download', '*.bc!', '*.tmp')
4
-
5
- function Get-PersonalRoots {
6
- $roots = @()
7
- $d = Join-Path $Script:P.U 'Downloads'
8
- if (Test-DirPresent $d) { $roots += (Get-FullPath $d) }
9
- return $roots
10
- }
11
-
12
- function Get-Targets18 {
13
- $t = @()
14
- foreach ($r in (Get-PersonalRoots)) { $t += (New-Target 18 'Partial / orphan downloads' $r -Kind cmd -Note ($Script:WS_PARTIAL_PATTERNS -join ' ')) }
15
- return $t
16
- }
17
-
18
- function Get-Targets19 {
19
- $t = @()
20
- foreach ($r in (Get-PersonalRoots)) { $t += (New-Target 19 "Files >= $($Script:WS.LargeFileMb) MB idle $($Script:WS.Days)+ days" $r -Kind cmd -Note 'moved to the Recycle Bin after you pick them') }
21
- return $t
22
- }
23
-
24
- function Get-FilesUnder {
25
- <# .SYNOPSIS Files under Root up to MaxDepth, never following links, never entering protected folders. #>
26
- param([string] $Root, [int] $MaxDepth = 4)
27
- $out = New-Object System.Collections.Generic.List[object]
28
- $stack = New-Object System.Collections.Generic.Stack[object]
29
- $stack.Push(@($Root, 0))
30
- while ($stack.Count -gt 0) {
31
- $item = $stack.Pop()
32
- foreach ($e in (Get-ChildEntries ([string]$item[0]))) {
33
- if (Test-ReparsePoint $e) { continue }
34
- if ($e -is [IO.DirectoryInfo]) { if ([int]$item[1] -lt $MaxDepth) { $stack.Push(@((Remove-LongPrefix $e.FullName), [int]$item[1] + 1)) }; continue }
35
- $out.Add($e)
36
- }
37
- }
38
- return $out
39
- }
40
-
41
- function Get-PartialDownloads {
42
- $list = @()
43
- foreach ($root in (Get-PersonalRoots)) {
44
- foreach ($f in (Get-FilesUnder -Root $root -MaxDepth 4)) {
45
- foreach ($pat in $Script:WS_PARTIAL_PATTERNS) {
46
- if ($f.Name -like $pat) { $list += [pscustomobject]@{ Path = (Remove-LongPrefix $f.FullName); Bytes = [long]$f.Length; Idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $f)).TotalDays); Root = $root }; break }
47
- }
48
- }
49
- }
50
- return @($list | Sort-Object Bytes -Descending)
51
- }
52
-
53
- function Get-LargeStaleFiles {
54
- $ws = $Script:WS
55
- $min = [long]$ws.LargeFileMb * 1MB
56
- $list = @()
57
- foreach ($root in (Get-PersonalRoots)) {
58
- foreach ($f in (Get-FilesUnder -Root $root -MaxDepth 4)) {
59
- if ([long]$f.Length -lt $min) { continue }
60
- $idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $f)).TotalDays)
61
- if ($idle -lt $ws.Days) { continue }
62
- $list += [pscustomobject]@{ Path = (Remove-LongPrefix $f.FullName); Bytes = [long]$f.Length; Idle = $idle; Root = $root }
63
- }
64
- }
65
- return @($list | Sort-Object Bytes -Descending | Select-Object -First 100)
66
- }
67
-
68
- function Show-FileTable {
69
- param([object[]] $Rows)
70
- Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'FILE') 'White'
71
- $i = 1
72
- foreach ($r in $Rows) { Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $r.Bytes), $r.Idle, $r.Path) 'Gray'; $i++ }
73
- }
74
-
75
- function Invoke-PersonalPicker {
76
- param([object[]] $Rows, [string] $Prompt)
77
- $ws = $Script:WS
78
- $Rows = @($Rows)
79
- if ($Rows.Count -eq 0) { Write-Info 'none found'; return }
80
- Show-FileTable $Rows
81
- $total = [long]0
82
- foreach ($r in $Rows) { $total += $r.Bytes }
83
- Write-Info ("total: " + (Format-Bytes $total))
84
- $dest = 'the Recycle Bin'
85
- if ($ws.Permanent) { $dest = 'PERMANENT deletion (--permanent)' }
86
- Write-Note "selected files go to $dest"
87
- $picks = @(Read-MultiSelect -Total $Rows.Count -NoAutoYes)
88
- if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
89
- if (-not $ws.DryRun) {
90
- # Personal files never auto-confirm: --yes does not apply here.
91
- if (-not (Confirm-Ui -Prompt "$Prompt ($($picks.Count) file(s))?" -Default 'n' -NoAutoYes)) { Write-Info 'skipped'; return }
92
- }
93
- foreach ($k in $picks) { $null = Send-ToRecycleBin -Path $Rows[$k - 1].Path -Within $Rows[$k - 1].Root }
94
- }
95
-
96
- function Invoke-Section18 {
97
- Write-SectionIntro @(
98
- 'Half-finished downloads left behind by browsers and download managers (.crdownload, .part, .tmp, ...).',
99
- 'You pick what goes; it lands in the Recycle Bin. Desktop and cloud-synced folders are never scanned.'
100
- )
101
- if (@(Get-PersonalRoots).Count -eq 0) { Write-Info 'no Downloads folder found'; return }
102
- Invoke-PersonalPicker -Rows @(Get-PartialDownloads) -Prompt 'Move the selected partial downloads to the Recycle Bin'
103
- }
104
-
105
- function Invoke-Section19 {
106
- $ws = $Script:WS
107
- Write-SectionIntro @(
108
- "Files of $($ws.LargeFileMb) MB or more in Downloads that nothing has touched for $($ws.Days)+ days: old installers, ISOs,",
109
- 'archives you already extracted. You pick what goes; it lands in the Recycle Bin.'
110
- )
111
- if (@(Get-PersonalRoots).Count -eq 0) { Write-Info 'no Downloads folder found'; return }
112
- Invoke-PersonalPicker -Rows @(Get-LargeStaleFiles) -Prompt 'Move the selected files to the Recycle Bin'
113
- }
114
-
115
- function Show-PersonalScan {
116
- <# .SYNOPSIS Read-only listing used by --scan. #>
117
- Write-Box 'Personal files (read-only)' 'Sections 18 and 19 are interactive only - nothing here is deleted'
118
- Write-Section 'Partial / orphan downloads'
119
- $p = @(Get-PartialDownloads)
120
- if ($p.Count -eq 0) { Write-Info 'none found' } else { Show-FileTable @($p | Select-Object -First 30) }
121
- Write-Section "Files >= $($Script:WS.LargeFileMb) MB idle $($Script:WS.Days)+ days in Downloads"
122
- $l = @(Get-LargeStaleFiles)
123
- if ($l.Count -eq 0) { Write-Info 'none found' } else { Show-FileTable @($l | Select-Object -First 30) }
124
- }
1
+ # personal.ps1 - sections 18 and 19: personal files in Downloads. Interactive only; the Recycle Bin is the destination.
2
+
3
+ $Script:WS_PARTIAL_PATTERNS = @('*.crdownload', '*.part', '*.partial', '*.fdmdownload', '*.opdownload', '*.!ut', '*.aria2', '*.download', '*.bc!', '*.tmp')
4
+
5
+ function Get-PersonalRoots {
6
+ $roots = @()
7
+ $d = Join-Path $Script:P.U 'Downloads'
8
+ if (Test-DirPresent $d) { $roots += (Get-FullPath $d) }
9
+ return $roots
10
+ }
11
+
12
+ function Get-Targets18 {
13
+ $t = @()
14
+ foreach ($r in (Get-PersonalRoots)) { $t += (New-Target 18 'Partial / orphan downloads' $r -Kind cmd -Note ($Script:WS_PARTIAL_PATTERNS -join ' ')) }
15
+ return $t
16
+ }
17
+
18
+ function Get-Targets19 {
19
+ $t = @()
20
+ foreach ($r in (Get-PersonalRoots)) { $t += (New-Target 19 "Files >= $($Script:WS.LargeFileMb) MB idle $($Script:WS.Days)+ days" $r -Kind cmd -Note 'moved to the Recycle Bin after you pick them') }
21
+ return $t
22
+ }
23
+
24
+ function Get-FilesUnder {
25
+ <# .SYNOPSIS Files under Root up to MaxDepth, never following links, never entering protected folders. #>
26
+ param([string] $Root, [int] $MaxDepth = 4)
27
+ $out = New-Object System.Collections.Generic.List[object]
28
+ $stack = New-Object System.Collections.Generic.Stack[object]
29
+ $stack.Push(@($Root, 0))
30
+ while ($stack.Count -gt 0) {
31
+ $item = $stack.Pop()
32
+ foreach ($e in (Get-ChildEntries ([string]$item[0]))) {
33
+ if (Test-ReparsePoint $e) { continue }
34
+ if ($e -is [IO.DirectoryInfo]) { if ([int]$item[1] -lt $MaxDepth) { $stack.Push(@((Remove-LongPrefix $e.FullName), [int]$item[1] + 1)) }; continue }
35
+ $out.Add($e)
36
+ }
37
+ }
38
+ return $out
39
+ }
40
+
41
+ function Get-PartialDownloads {
42
+ $list = @()
43
+ foreach ($root in (Get-PersonalRoots)) {
44
+ foreach ($f in (Get-FilesUnder -Root $root -MaxDepth 4)) {
45
+ foreach ($pat in $Script:WS_PARTIAL_PATTERNS) {
46
+ if ($f.Name -like $pat) { $list += [pscustomobject]@{ Path = (Remove-LongPrefix $f.FullName); Bytes = [long]$f.Length; Idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $f)).TotalDays); Root = $root }; break }
47
+ }
48
+ }
49
+ }
50
+ return @($list | Sort-Object Bytes -Descending)
51
+ }
52
+
53
+ function Get-LargeStaleFiles {
54
+ $ws = $Script:WS
55
+ $min = [long]$ws.LargeFileMb * 1MB
56
+ $list = @()
57
+ foreach ($root in (Get-PersonalRoots)) {
58
+ foreach ($f in (Get-FilesUnder -Root $root -MaxDepth 4)) {
59
+ if ([long]$f.Length -lt $min) { continue }
60
+ $idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $f)).TotalDays)
61
+ if ($idle -lt $ws.Days) { continue }
62
+ $list += [pscustomobject]@{ Path = (Remove-LongPrefix $f.FullName); Bytes = [long]$f.Length; Idle = $idle; Root = $root }
63
+ }
64
+ }
65
+ return @($list | Sort-Object Bytes -Descending | Select-Object -First 100)
66
+ }
67
+
68
+ function Show-FileTable {
69
+ param([object[]] $Rows)
70
+ Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'FILE') 'White'
71
+ $i = 1
72
+ foreach ($r in $Rows) { Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $r.Bytes), $r.Idle, $r.Path) 'Gray'; $i++ }
73
+ }
74
+
75
+ function Invoke-PersonalPicker {
76
+ param([object[]] $Rows, [string] $Prompt, [int] $Section = 0)
77
+ $ws = $Script:WS
78
+ $Rows = @($Rows)
79
+ if ($Rows.Count -eq 0) { Write-Info 'none found'; return }
80
+ Show-FileTable $Rows
81
+ $total = [long]0
82
+ $i = 1
83
+ foreach ($r in $Rows) { $total += $r.Bytes; Add-JsonCandidate -Section $Section -Index $i -Path $r.Path -Bytes $r.Bytes -IdleDays $r.Idle; $i++ }
84
+ Write-Info ("total: " + (Format-Bytes $total))
85
+ $dest = 'the Recycle Bin'
86
+ if ($ws.Permanent) { $dest = 'PERMANENT deletion (--permanent)' }
87
+ Write-Note "selected files go to $dest"
88
+ $picks = @(Read-MultiSelect -Total $Rows.Count -NoAutoYes -Candidates @($Rows | ForEach-Object { $_.Path }))
89
+ if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
90
+ if (-not $ws.DryRun) {
91
+ # Personal files never auto-confirm: --yes does not apply here. An explicit --select / --select-file
92
+ # choice does, because it is a person naming these exact files in advance.
93
+ if (-not (Confirm-Ui -Prompt "$Prompt ($($picks.Count) file(s))?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
94
+ }
95
+ foreach ($k in $picks) { $null = Send-ToRecycleBin -Path $Rows[$k - 1].Path -Within $Rows[$k - 1].Root }
96
+ }
97
+
98
+ function Invoke-Section18 {
99
+ Write-SectionIntro @(
100
+ 'Half-finished downloads left behind by browsers and download managers (.crdownload, .part, .tmp, ...).',
101
+ 'You pick what goes; it lands in the Recycle Bin. Desktop and cloud-synced folders are never scanned.'
102
+ )
103
+ if (@(Get-PersonalRoots).Count -eq 0) { Write-Info 'no Downloads folder found'; return }
104
+ Invoke-PersonalPicker -Rows @(Get-PartialDownloads) -Prompt 'Move the selected partial downloads to the Recycle Bin' -Section 18
105
+ }
106
+
107
+ function Invoke-Section19 {
108
+ $ws = $Script:WS
109
+ Write-SectionIntro @(
110
+ "Files of $($ws.LargeFileMb) MB or more in Downloads that nothing has touched for $($ws.Days)+ days: old installers, ISOs,",
111
+ 'archives you already extracted. You pick what goes; it lands in the Recycle Bin.'
112
+ )
113
+ if (@(Get-PersonalRoots).Count -eq 0) { Write-Info 'no Downloads folder found'; return }
114
+ Invoke-PersonalPicker -Rows @(Get-LargeStaleFiles) -Prompt 'Move the selected files to the Recycle Bin' -Section 19
115
+ }
116
+
117
+ function Show-PersonalScan {
118
+ <# .SYNOPSIS Read-only listing used by --scan. #>
119
+ Write-Box 'Personal files (read-only)' 'Sections 18 and 19 are interactive only - nothing here is deleted'
120
+ Write-Section 'Partial / orphan downloads'
121
+ $p = @(Get-PartialDownloads)
122
+ if ($p.Count -eq 0) { Write-Info 'none found' } else { Show-FileTable @($p | Select-Object -First 30) }
123
+ Write-Section "Files >= $($Script:WS.LargeFileMb) MB idle $($Script:WS.Days)+ days in Downloads"
124
+ $l = @(Get-LargeStaleFiles)
125
+ if ($l.Count -eq 0) { Write-Info 'none found' } else { Show-FileTable @($l | Select-Object -First 30) }
126
+ }