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 +1 -0
- coldstore/__main__.py +240 -0
- coldstore/actions.py +157 -0
- coldstore/analyze.py +135 -0
- coldstore/apply.py +94 -0
- coldstore/archive.py +255 -0
- coldstore/config.py +91 -0
- coldstore/estimator.py +43 -0
- coldstore/gui.py +461 -0
- coldstore/integration.py +150 -0
- coldstore/journal.py +40 -0
- coldstore/media.py +283 -0
- coldstore/recovery.py +208 -0
- coldstore/report.py +211 -0
- coldstore/rules.py +111 -0
- coldstore/scanner.py +170 -0
- coldstore/winapi.py +58 -0
- disk_coldstore-0.1.0.dist-info/METADATA +130 -0
- disk_coldstore-0.1.0.dist-info/RECORD +23 -0
- disk_coldstore-0.1.0.dist-info/WHEEL +5 -0
- disk_coldstore-0.1.0.dist-info/entry_points.txt +5 -0
- disk_coldstore-0.1.0.dist-info/licenses/LICENSE +21 -0
- disk_coldstore-0.1.0.dist-info/top_level.txt +1 -0
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}]")
|