disk-coldstore 0.1.0__py3-none-any.whl

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.
coldstore/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
coldstore/__main__.py ADDED
@@ -0,0 +1,240 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+
10
+ from .analyze import print_analysis, print_tree
11
+ from .report import build_candidates, print_report
12
+ from .rules import classify
13
+ from .scanner import scan
14
+
15
+
16
+ def _do_scan(root: Path, verbose: bool):
17
+ con = Console()
18
+ con.print(f"[bold]Scanning[/bold] {root} ...")
19
+ started = time.time()
20
+
21
+ def on_progress(current: Path, count: int) -> None:
22
+ con.print(f" [dim]{count} folders scanned — {current}[/dim]", highlight=False)
23
+
24
+ result = scan(root, on_progress=on_progress if verbose else None)
25
+ con.print(
26
+ f"[dim]Scan finished in {time.time() - started:.1f}s — "
27
+ f"{result.aggregate.total_folder_count:,} folders, "
28
+ f"{result.aggregate.total_file_count:,} files, "
29
+ f"{result.aggregate.total_bytes / (1024**3):.2f} GB[/dim]"
30
+ )
31
+ return result
32
+
33
+
34
+ def cmd_scan(args: argparse.Namespace) -> int:
35
+ root = Path(args.path).resolve()
36
+ if not root.exists():
37
+ print(f"error: path does not exist: {root}", file=sys.stderr)
38
+ return 2
39
+
40
+ result = _do_scan(root, args.verbose)
41
+ min_size = args.min_size_mb * 1024 * 1024
42
+ scanned = [(s, classify(s, min_size_bytes=min_size,
43
+ cool_age_days=args.cool_days,
44
+ cold_age_days=args.cold_days)) for s in result.folders]
45
+ candidates = build_candidates(scanned, min_size_bytes=min_size)
46
+ print_report(root, scanned, candidates, top_n=args.top)
47
+ return 0
48
+
49
+
50
+ def cmd_analyze(args: argparse.Namespace) -> int:
51
+ root = Path(args.path).resolve()
52
+ if not root.exists():
53
+ print(f"error: path does not exist: {root}", file=sys.stderr)
54
+ return 2
55
+
56
+ result = _do_scan(root, args.verbose)
57
+ print_analysis(root, result.folders, result.aggregate,
58
+ top_folders=args.top_folders,
59
+ top_files=args.top_files,
60
+ top_exts=args.top_exts)
61
+ if args.tree:
62
+ Console().print()
63
+ print_tree(root, result.folders, max_depth=args.tree_depth, min_share=args.tree_min_share / 100.0)
64
+ return 0
65
+
66
+
67
+ def cmd_apply(args: argparse.Namespace) -> int:
68
+ from .apply import run_apply
69
+ root = Path(args.path).resolve()
70
+ if not root.exists():
71
+ print(f"error: path does not exist: {root}", file=sys.stderr)
72
+ return 2
73
+
74
+ result = _do_scan(root, args.verbose)
75
+ min_size = args.min_size_mb * 1024 * 1024
76
+ scanned = [(s, classify(s, min_size_bytes=min_size,
77
+ cool_age_days=args.cool_days,
78
+ cold_age_days=args.cold_days)) for s in result.folders]
79
+ candidates = build_candidates(scanned, min_size_bytes=min_size)
80
+ return run_apply(
81
+ root, candidates,
82
+ do_junk=args.junk,
83
+ do_cool=args.cool,
84
+ do_cold=args.cold,
85
+ yes=args.yes,
86
+ )
87
+
88
+
89
+ def cmd_restore(args: argparse.Namespace) -> int:
90
+ from .recovery import cmd_restore as _restore
91
+ return _restore(Path(args.stub))
92
+
93
+
94
+ def cmd_journal(args: argparse.Namespace) -> int:
95
+ from .recovery import cmd_journal_list
96
+ return cmd_journal_list(limit=args.limit)
97
+
98
+
99
+ def cmd_undo(args: argparse.Namespace) -> int:
100
+ from .recovery import cmd_undo as _undo
101
+ return _undo(entry_number=args.entry, last_n=args.last)
102
+
103
+
104
+ def cmd_gui(args: argparse.Namespace) -> int:
105
+ from .gui import run_gui
106
+ run_gui()
107
+ return 0
108
+
109
+
110
+ def cmd_media(args: argparse.Namespace) -> int:
111
+ from .media import cmd_media_apply, cmd_media_scan, restore_one
112
+ if args.media_op == "scan":
113
+ return cmd_media_scan(Path(args.path))
114
+ if args.media_op == "apply":
115
+ return cmd_media_apply(Path(args.path), yes=args.yes)
116
+ if args.media_op == "restore":
117
+ result = restore_one(Path(args.jxl))
118
+ if result.ok:
119
+ print(f"restored: {result.path} {result.note}")
120
+ return 0
121
+ print(f"failed: {result.error}", file=sys.stderr)
122
+ return 1
123
+ return 2
124
+
125
+
126
+ def cmd_register(args: argparse.Namespace) -> int:
127
+ from .integration import register_file_association, unregister_file_association
128
+ if args.uninstall:
129
+ ok, msg = unregister_file_association()
130
+ else:
131
+ ok, msg = register_file_association()
132
+ print(msg)
133
+ return 0 if ok else 1
134
+
135
+
136
+ def cmd_schedule(args: argparse.Namespace) -> int:
137
+ from .integration import (create_scheduled_scan, delete_scheduled_scan,
138
+ query_scheduled_scan)
139
+ if args.delete:
140
+ ok, msg = delete_scheduled_scan()
141
+ elif args.query:
142
+ ok, msg = query_scheduled_scan()
143
+ else:
144
+ if not args.target:
145
+ print("error: --target is required when creating a schedule", file=sys.stderr)
146
+ return 2
147
+ ok, msg = create_scheduled_scan(
148
+ target=Path(args.target),
149
+ cadence=args.cadence,
150
+ min_size_mb=args.min_size_mb,
151
+ )
152
+ print(msg)
153
+ return 0 if ok else 1
154
+
155
+
156
+ def main(argv: list[str] | None = None) -> int:
157
+ p = argparse.ArgumentParser(prog="coldstore", description="Tiered laptop storage reclaimer.")
158
+ sub = p.add_subparsers(dest="command", required=True)
159
+
160
+ ps = sub.add_parser("scan", help="scan and report reclaimable space (no changes made)")
161
+ ps.add_argument("path")
162
+ ps.add_argument("--top", type=int, default=40)
163
+ ps.add_argument("--min-size-mb", type=int, default=50)
164
+ ps.add_argument("--cool-days", type=int, default=30)
165
+ ps.add_argument("--cold-days", type=int, default=180)
166
+ ps.add_argument("-v", "--verbose", action="store_true")
167
+ ps.set_defaults(func=cmd_scan)
168
+
169
+ pa = sub.add_parser("analyze", help="WizTree-style disk analysis (no changes made)")
170
+ pa.add_argument("path")
171
+ pa.add_argument("--top-folders", type=int, default=25)
172
+ pa.add_argument("--top-files", type=int, default=25)
173
+ pa.add_argument("--top-exts", type=int, default=20)
174
+ pa.add_argument("--tree", action="store_true", help="also print an indented folder tree")
175
+ pa.add_argument("--tree-depth", type=int, default=3)
176
+ pa.add_argument("--tree-min-share", type=float, default=1.0,
177
+ help="prune tree branches below this percentage of root size (default 1.0)")
178
+ pa.add_argument("-v", "--verbose", action="store_true")
179
+ pa.set_defaults(func=cmd_analyze)
180
+
181
+ pap = sub.add_parser("apply", help="perform recommended actions (interactive by default)")
182
+ pap.add_argument("path")
183
+ pap.add_argument("--min-size-mb", type=int, default=50)
184
+ pap.add_argument("--cool-days", type=int, default=30)
185
+ pap.add_argument("--cold-days", type=int, default=180)
186
+ pap.add_argument("--junk", action="store_true", help="process JUNK candidates (Recycle Bin)")
187
+ pap.add_argument("--cool", action="store_true", help="process COOL candidates (compact.exe LZX)")
188
+ pap.add_argument("--cold", action="store_true", help="process COLD candidates (7z archive + stub)")
189
+ pap.add_argument("--yes", action="store_true", help="do not prompt for confirmation")
190
+ pap.add_argument("-v", "--verbose", action="store_true")
191
+ pap.set_defaults(func=cmd_apply)
192
+
193
+ pr = sub.add_parser("restore", help="restore a .coldstore stub back to its original folder")
194
+ pr.add_argument("stub", help="path to the .coldstore stub file")
195
+ pr.set_defaults(func=cmd_restore)
196
+
197
+ pj = sub.add_parser("journal", help="show recent journal entries")
198
+ pj.add_argument("--limit", type=int, default=30)
199
+ pj.set_defaults(func=cmd_journal)
200
+
201
+ pu = sub.add_parser("undo", help="undo one or more previous actions from the journal")
202
+ grp = pu.add_mutually_exclusive_group()
203
+ grp.add_argument("--entry", type=int, help="undo a specific journal entry number (see 'journal')")
204
+ grp.add_argument("--last", type=int, help="undo the last N successful actions")
205
+ pu.set_defaults(func=cmd_undo)
206
+
207
+ pg = sub.add_parser("gui", help="launch the graphical interface")
208
+ pg.set_defaults(func=cmd_gui)
209
+
210
+ pmed = sub.add_parser("media", help="bit-exact JPEG -> JPEG XL lossless transcode (requires libjxl)")
211
+ med_sub = pmed.add_subparsers(dest="media_op", required=True)
212
+ med_scan = med_sub.add_parser("scan", help="report JPEGs and estimated savings")
213
+ med_scan.add_argument("path")
214
+ med_apply = med_sub.add_parser("apply", help="transcode JPEGs to JXL with byte-exact verification")
215
+ med_apply.add_argument("path")
216
+ med_apply.add_argument("--yes", action="store_true")
217
+ med_restore = med_sub.add_parser("restore", help="restore a .jxl back to its bit-exact .jpg")
218
+ med_restore.add_argument("jxl")
219
+ pmed.set_defaults(func=cmd_media)
220
+
221
+ preg = sub.add_parser("register",
222
+ help="register .coldstore file association (double-click to restore)")
223
+ preg.add_argument("--uninstall", action="store_true", help="remove the association instead")
224
+ preg.set_defaults(func=cmd_register)
225
+
226
+ psch = sub.add_parser("schedule",
227
+ help="create/query/delete a Windows scheduled task that runs 'scan' periodically")
228
+ psch.add_argument("--target", help="folder to scan (required when creating a schedule)")
229
+ psch.add_argument("--cadence", choices=["daily", "weekly", "monthly"], default="weekly")
230
+ psch.add_argument("--min-size-mb", type=int, default=100)
231
+ psch.add_argument("--query", action="store_true", help="show the existing scheduled task")
232
+ psch.add_argument("--delete", action="store_true", help="remove the scheduled task")
233
+ psch.set_defaults(func=cmd_schedule)
234
+
235
+ args = p.parse_args(argv)
236
+ return args.func(args)
237
+
238
+
239
+ if __name__ == "__main__":
240
+ raise SystemExit(main())
coldstore/actions.py ADDED
@@ -0,0 +1,157 @@
1
+ """Pure per-candidate action functions used by both the CLI and GUI.
2
+
3
+ Each `do_*` function returns an `ItemResult` and does NOT print or journal.
4
+ The caller owns UI + journaling."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import subprocess
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .archive import ArchiveError, archive_folder
16
+ from .journal import Action, Journal
17
+ from .report import Candidate
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class ItemResult:
22
+ ok: bool
23
+ op: str
24
+ path: str
25
+ before_bytes: int = 0
26
+ after_bytes: int = 0
27
+ note: str = ""
28
+ error: str | None = None
29
+ extras: dict[str, Any] = field(default_factory=dict)
30
+
31
+ @property
32
+ def bytes_freed(self) -> int:
33
+ return max(0, self.before_bytes - self.after_bytes)
34
+
35
+ @property
36
+ def ratio(self) -> float:
37
+ if self.after_bytes <= 0:
38
+ return 1.0
39
+ return self.before_bytes / self.after_bytes
40
+
41
+
42
+ def _send_to_recycle_bin(path: Path) -> tuple[bool, str]:
43
+ try:
44
+ import send2trash
45
+ send2trash.send2trash(str(path))
46
+ return True, "send2trash"
47
+ except ImportError:
48
+ pass
49
+ except Exception as exc:
50
+ return False, f"send2trash failed: {exc!r}"
51
+
52
+ ps_script = (
53
+ "Add-Type -AssemblyName Microsoft.VisualBasic; "
54
+ "[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory("
55
+ f"'{str(path).replace(chr(39), chr(39)*2)}',"
56
+ "'OnlyErrorDialogs','SendToRecycleBin')"
57
+ )
58
+ proc = subprocess.run(
59
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
60
+ capture_output=True, text=True,
61
+ )
62
+ if proc.returncode != 0:
63
+ return False, f"powershell fallback failed: {proc.stderr.strip()[:200]}"
64
+ return True, "powershell-recyclebin"
65
+
66
+
67
+ _COMPACT_STORED_RE = re.compile(
68
+ r"([\d,\.\s]+)\s+total bytes of data are stored in\s+([\d,\.\s]+)\s+bytes",
69
+ re.IGNORECASE,
70
+ )
71
+
72
+
73
+ def _parse_compact_output(out: str) -> tuple[int, int] | None:
74
+ m = _COMPACT_STORED_RE.search(out)
75
+ if not m:
76
+ return None
77
+ def _n(s: str) -> int:
78
+ return int(s.replace(",", "").replace(".", "").strip())
79
+ try:
80
+ return _n(m.group(1)), _n(m.group(2))
81
+ except ValueError:
82
+ return None
83
+
84
+
85
+ def _compact_lzx(path: Path) -> tuple[bool, int, int, str]:
86
+ args = ["compact.exe", "/c", "/s", "/a", "/i", "/exe:LZX"]
87
+ try:
88
+ proc = subprocess.run(args, capture_output=True, text=True, cwd=str(path))
89
+ except FileNotFoundError:
90
+ return False, 0, 0, "compact.exe not found on PATH"
91
+ except OSError as exc:
92
+ return False, 0, 0, f"failed to chdir into {path}: {exc!r}"
93
+
94
+ out = (proc.stdout or "") + "\n" + (proc.stderr or "")
95
+ parsed = _parse_compact_output(out)
96
+ if parsed:
97
+ return proc.returncode == 0, parsed[0], parsed[1], "compact.exe stored/on-disk"
98
+ return proc.returncode == 0, 0, 0, (out.splitlines()[-1] if out.strip() else "no output")
99
+
100
+
101
+ def do_recycle(candidate: Candidate) -> ItemResult:
102
+ before = candidate.stats.total_bytes
103
+ ok, note = _send_to_recycle_bin(candidate.stats.path)
104
+ return ItemResult(
105
+ ok=ok, op="recycle", path=str(candidate.stats.path),
106
+ before_bytes=before, after_bytes=0 if ok else before,
107
+ note=note, error=None if ok else note,
108
+ )
109
+
110
+
111
+ def do_compact_lzx(candidate: Candidate) -> ItemResult:
112
+ ok, before, after, note = _compact_lzx(candidate.stats.path)
113
+ if before == 0:
114
+ before = candidate.stats.total_bytes
115
+ return ItemResult(
116
+ ok=ok, op="compact_lzx", path=str(candidate.stats.path),
117
+ before_bytes=before, after_bytes=(after or before),
118
+ note=note, error=None if ok else note,
119
+ extras={"algorithm": "LZX"},
120
+ )
121
+
122
+
123
+ def do_archive(candidate: Candidate) -> ItemResult:
124
+ before = candidate.stats.total_bytes
125
+ try:
126
+ stub_path, meta = archive_folder(candidate.stats.path)
127
+ except ArchiveError as exc:
128
+ return ItemResult(
129
+ ok=False, op="archive_7z", path=str(candidate.stats.path),
130
+ before_bytes=before, after_bytes=before,
131
+ note="archive failed", error=str(exc),
132
+ )
133
+ return ItemResult(
134
+ ok=True, op="archive_7z", path=str(candidate.stats.path),
135
+ before_bytes=meta.original_bytes, after_bytes=meta.archive_bytes,
136
+ note="archived",
137
+ extras={
138
+ "stub_path": str(stub_path),
139
+ "original_name": meta.original_name,
140
+ "sha256_archive": meta.sha256_archive,
141
+ },
142
+ )
143
+
144
+
145
+ def record_result(journal: Journal, candidate: Candidate, result: ItemResult) -> None:
146
+ journal.record(Action(
147
+ ts=time.time(),
148
+ op=result.op,
149
+ path=result.path,
150
+ verdict=candidate.classification.verdict.value,
151
+ bytes_before=result.before_bytes,
152
+ bytes_after=result.after_bytes,
153
+ reversible=True,
154
+ detail={"note": result.note, **result.extras},
155
+ outcome="ok" if result.ok else "error",
156
+ error=result.error,
157
+ ))
coldstore/analyze.py ADDED
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ from .scanner import FolderStats, ScanAggregate
9
+
10
+
11
+ def _fmt_bytes(n: int) -> str:
12
+ if n == 0:
13
+ return "0 B"
14
+ for unit in ("B", "KB", "MB", "GB", "TB"):
15
+ if abs(n) < 1024:
16
+ return f"{n:.1f} {unit}"
17
+ n /= 1024
18
+ return f"{n:.1f} PB"
19
+
20
+
21
+ def _bar(fraction: float, width: int = 20) -> str:
22
+ filled = int(round(fraction * width))
23
+ return "█" * filled + "░" * (width - filled)
24
+
25
+
26
+ def print_analysis(
27
+ root: Path,
28
+ folders: list[FolderStats],
29
+ agg: ScanAggregate,
30
+ top_folders: int = 25,
31
+ top_files: int = 25,
32
+ top_exts: int = 20,
33
+ ) -> None:
34
+ con = Console()
35
+
36
+ con.rule(f"[bold]ColdStore disk analysis[/bold] — {root}")
37
+
38
+ header = Table.grid(padding=(0, 2))
39
+ header.add_column(style="dim")
40
+ header.add_column(style="bold")
41
+ header.add_row("Total size", _fmt_bytes(agg.total_bytes))
42
+ header.add_row("Files", f"{agg.total_file_count:,}")
43
+ header.add_row("Folders", f"{agg.total_folder_count:,}")
44
+ header.add_row("Distinct extensions", str(len(agg.ext_bytes)))
45
+ if agg.error_count:
46
+ header.add_row("Errors (permission etc.)", f"[yellow]{agg.error_count}[/yellow]")
47
+ con.print(header)
48
+
49
+ con.print()
50
+ con.rule(f"[bold]Top {top_folders} largest folders[/bold]", style="dim")
51
+ ranked = sorted(folders, key=lambda f: -f.total_bytes)[:top_folders]
52
+ max_bytes = ranked[0].total_bytes if ranked else 1
53
+
54
+ folder_table = Table(show_header=True, header_style="bold")
55
+ folder_table.add_column("size", justify="right", width=10, style="bold cyan")
56
+ folder_table.add_column("share", width=22)
57
+ folder_table.add_column("files", justify="right", width=8, style="dim")
58
+ folder_table.add_column("path", overflow="fold")
59
+ for f in ranked:
60
+ frac = f.total_bytes / max_bytes if max_bytes else 0
61
+ folder_table.add_row(
62
+ _fmt_bytes(f.total_bytes),
63
+ _bar(frac),
64
+ f"{f.total_file_count:,}",
65
+ str(f.path),
66
+ )
67
+ con.print(folder_table)
68
+
69
+ con.print()
70
+ con.rule(f"[bold]Top {top_files} largest files[/bold]", style="dim")
71
+ largest = agg.largest_files()[:top_files]
72
+ file_table = Table(show_header=True, header_style="bold")
73
+ file_table.add_column("size", justify="right", width=10, style="bold magenta")
74
+ file_table.add_column("path", overflow="fold")
75
+ for size, path in largest:
76
+ file_table.add_row(_fmt_bytes(size), path)
77
+ con.print(file_table)
78
+
79
+ con.print()
80
+ con.rule(f"[bold]Top {top_exts} extensions by size[/bold]", style="dim")
81
+ ext_table = Table(show_header=True, header_style="bold")
82
+ ext_table.add_column("ext", width=12)
83
+ ext_table.add_column("bytes", justify="right", width=10, style="bold green")
84
+ ext_table.add_column("share", width=22)
85
+ ext_table.add_column("count", justify="right", width=10, style="dim")
86
+ ext_ranked = agg.ext_bytes.most_common(top_exts)
87
+ max_ext_bytes = ext_ranked[0][1] if ext_ranked else 1
88
+ for ext, size in ext_ranked:
89
+ frac = size / max_ext_bytes if max_ext_bytes else 0
90
+ ext_table.add_row(
91
+ ext or "(no ext)",
92
+ _fmt_bytes(size),
93
+ _bar(frac),
94
+ f"{agg.ext_counts[ext]:,}",
95
+ )
96
+ con.print(ext_table)
97
+
98
+
99
+ def print_tree(
100
+ root: Path,
101
+ folders: list[FolderStats],
102
+ max_depth: int = 3,
103
+ min_share: float = 0.01,
104
+ ) -> None:
105
+ """WizTree-style indented tree, sorted by size, pruned to significant folders."""
106
+ con = Console()
107
+ by_path = {f.path: f for f in folders}
108
+ root_stats = next((f for f in folders if f.path == root.resolve()), None)
109
+ if root_stats is None:
110
+ con.print("[yellow]root not found in scan[/yellow]")
111
+ return
112
+ root_bytes = root_stats.total_bytes or 1
113
+
114
+ con.rule(f"[bold]Folder tree[/bold] — {root} ([dim]≥ {min_share:.0%} of root, depth ≤ {max_depth}[/dim])")
115
+
116
+ def walk(path: Path, depth: int) -> None:
117
+ stats = by_path.get(path)
118
+ if stats is None:
119
+ return
120
+ share = stats.total_bytes / root_bytes
121
+ if depth > 0 and share < min_share:
122
+ return
123
+ indent = " " * depth
124
+ con.print(
125
+ f"{indent}[bold cyan]{_fmt_bytes(stats.total_bytes):>10s}[/bold cyan] "
126
+ f"[dim]{share*100:5.1f}%[/dim] {path.name or str(path)}"
127
+ )
128
+ if depth >= max_depth:
129
+ return
130
+ children = [f for f in folders if f.path.parent == path]
131
+ children.sort(key=lambda f: -f.total_bytes)
132
+ for c in children:
133
+ walk(c.path, depth + 1)
134
+
135
+ walk(root.resolve(), 0)
coldstore/apply.py ADDED
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from rich.console import Console
6
+ from rich.prompt import Confirm
7
+
8
+ from .actions import do_archive, do_compact_lzx, do_recycle, record_result
9
+ from .journal import Journal
10
+ from .report import Candidate
11
+ from .rules import Verdict
12
+
13
+
14
+ def _fmt_bytes(n: int) -> str:
15
+ if n == 0:
16
+ return "0 B"
17
+ for unit in ("B", "KB", "MB", "GB", "TB"):
18
+ if abs(n) < 1024:
19
+ return f"{n:.1f} {unit}"
20
+ n /= 1024
21
+ return f"{n:.1f} PB"
22
+
23
+
24
+ def run_apply(
25
+ root: Path,
26
+ candidates: list[Candidate],
27
+ do_junk: bool,
28
+ do_cool: bool,
29
+ do_cold: bool,
30
+ yes: bool,
31
+ ) -> int:
32
+ con = Console()
33
+ journal = Journal()
34
+
35
+ if not (do_junk or do_cool or do_cold):
36
+ con.print("[yellow]nothing to do — pass --junk, --cool, and/or --cold[/yellow]")
37
+ return 2
38
+
39
+ junk = [c for c in candidates if c.classification.verdict == Verdict.JUNK]
40
+ cool = [c for c in candidates if c.classification.verdict == Verdict.COOL]
41
+ cold = [c for c in candidates if c.classification.verdict == Verdict.COLD]
42
+
43
+ def _preview(items: list[Candidate], title: str, style: str) -> None:
44
+ total = sum(c.stats.total_bytes for c in items)
45
+ con.rule(f"[bold {style}]{title}[/bold {style}]")
46
+ con.print(f"{len(items)} folders, total {_fmt_bytes(total)}")
47
+ for c in items[:10]:
48
+ con.print(f" [dim]{_fmt_bytes(c.stats.total_bytes):>10s}[/dim] {c.stats.path}")
49
+ if len(items) > 10:
50
+ con.print(f" [dim]... and {len(items) - 10} more[/dim]")
51
+
52
+ if do_junk and junk:
53
+ _preview(junk, "JUNK — Recycle Bin", "red")
54
+ if yes or Confirm.ask("Send these to the Recycle Bin?", default=False):
55
+ _run_batch(junk, do_recycle, "Recycled", "green", con, journal)
56
+
57
+ if do_cool and cool:
58
+ _preview(cool, "COOL — NTFS/LZX compression", "cyan")
59
+ if yes or Confirm.ask("Compress these in place (compact.exe LZX)?", default=False):
60
+ _run_batch(cool, do_compact_lzx, "Compressed", "cyan", con, journal, show_ratio=True)
61
+
62
+ if do_cold and cold:
63
+ _preview(cold, "COLD — 7z archive + stub", "blue")
64
+ if yes or Confirm.ask("Archive these to .coldstore stubs (deletes originals after verify)?", default=False):
65
+ _run_batch(cold, do_archive, "Archived", "blue", con, journal, show_ratio=True, show_stub=True)
66
+
67
+ con.print()
68
+ con.print(f"[dim]Journal: {journal.file}[/dim]")
69
+ return 0
70
+
71
+
72
+ def _run_batch(items, action_fn, verb, style, con, journal, show_ratio=False, show_stub=False):
73
+ freed = 0
74
+ ok_count = 0
75
+ for c in items:
76
+ con.print(f" [dim]{verb.lower()}[/dim] {c.stats.path} ...")
77
+ result = action_fn(c)
78
+ record_result(journal, c, result)
79
+ if result.ok:
80
+ freed += result.bytes_freed
81
+ ok_count += 1
82
+ line = f" [green]✓[/green] {c.stats.path}"
83
+ if show_ratio and result.before_bytes:
84
+ line += (f" {_fmt_bytes(result.before_bytes)} → "
85
+ f"{_fmt_bytes(result.after_bytes)} "
86
+ f"[bold]({result.ratio:.2f}x)[/bold]")
87
+ else:
88
+ line += f" ({_fmt_bytes(result.before_bytes)})"
89
+ con.print(line)
90
+ if show_stub and "stub_path" in result.extras:
91
+ con.print(f" [dim]stub: {result.extras['stub_path']}[/dim]")
92
+ else:
93
+ con.print(f" [red]✗[/red] {c.stats.path} [dim]{result.error or result.note}[/dim]")
94
+ con.print(f"[bold {style}]{verb} {ok_count}/{len(items)} folders, freed {_fmt_bytes(freed)}[/bold {style}]")