disk-coldstore 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: disk-coldstore
3
+ Version: 0.1.0
4
+ Summary: Tiered laptop storage reclaimer: find junk, LZX-compress cool folders, 7z-archive cold ones — all reversible.
5
+ Author-email: Arya <arya1262023@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Arya-126/coldstore
8
+ Project-URL: Repository, https://github.com/Arya-126/coldstore
9
+ Project-URL: Issues, https://github.com/Arya-126/coldstore/issues
10
+ Keywords: storage,disk-space,compression,7z,lzx,ntfs,windows,cleanup,archive
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Environment :: Win32 (MS Windows)
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: Microsoft :: Windows :: Windows 10
16
+ Classifier: Operating System :: Microsoft :: Windows :: Windows 11
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: System :: Archiving :: Compression
24
+ Classifier: Topic :: System :: Filesystems
25
+ Classifier: Topic :: Utilities
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: rich>=13.0
30
+ Requires-Dist: send2trash>=1.8
31
+ Requires-Dist: py7zr>=0.20
32
+ Dynamic: license-file
33
+
34
+ # ColdStore
35
+
36
+ Tiered laptop storage reclaimer for Windows. Point it at a folder, get an
37
+ honest, categorized plan of what can be freed, then apply the plan — every
38
+ action is journaled and reversible.
39
+
40
+ ## What it does
41
+
42
+ Three tiers, safest first:
43
+
44
+ | Tier | Trigger | Action | Reversible via |
45
+ | :--- | :--- | :--- | :--- |
46
+ | **JUNK** | Regenerable folders (`node_modules`, `.venv`, `__pycache__`, browser caches, `Temp`) or app caches | Send to Recycle Bin | Restore via Shell.Application COM |
47
+ | **COOL** | Not accessed in 30+ days, non-media content | NTFS/LZX compress in place with `compact.exe` | `compact /u /s /a /i /exe` |
48
+ | **COLD** | Not accessed in 180+ days | 7z archive; original folder replaced with a `.coldstore` stub file | `coldstore restore <stub>` |
49
+
50
+ Bonus: bit-exact **JPEG -> JPEG XL** lossless transcode using
51
+ [libjxl](https://github.com/libjxl/libjxl) — reversible to the byte-identical
52
+ original JPEG.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install coldstore
58
+ ```
59
+
60
+ Optional external tools that unlock extra performance / features:
61
+
62
+ - **[7-Zip](https://www.7-zip.org/)** — if `7z.exe` is on PATH, cold archiving
63
+ uses the native binary (much faster on large folders). Otherwise the pure-Python
64
+ `py7zr` fallback is used automatically.
65
+ - **[libjxl](https://github.com/libjxl/libjxl/releases)** — `cjxl.exe` /
66
+ `djxl.exe` on PATH enable the bit-exact JPEG -> JXL media tier.
67
+
68
+ ## Usage
69
+
70
+ ```
71
+ coldstore scan <path> # dry-run report of reclaimable space
72
+ coldstore analyze <path> [--tree] # WizTree-style disk analysis
73
+ coldstore apply <path> --junk --cool --cold # do the work
74
+ coldstore restore <stub.coldstore> # restore an archived folder
75
+ coldstore undo --last N | --entry K # undo previous actions
76
+ coldstore journal [--limit N] # timeline of everything done
77
+
78
+ coldstore gui # graphical interface
79
+ coldstore register [--uninstall] # register .coldstore file association
80
+ coldstore schedule --target <path> --cadence weekly
81
+
82
+ coldstore media scan|apply|restore <path> # bit-exact JPEG <-> JXL
83
+ ```
84
+
85
+ Recommended first run — read-only, no changes:
86
+
87
+ ```powershell
88
+ coldstore analyze C:\Users\%USERNAME% --tree --tree-depth 4 --tree-min-share 1
89
+ coldstore scan C:\Users\%USERNAME% --min-size-mb 100
90
+ ```
91
+
92
+ Then, when you're ready to actually reclaim space:
93
+
94
+ ```powershell
95
+ coldstore apply C:\Users\%USERNAME% --min-size-mb 100 --junk --cool --cold
96
+ ```
97
+
98
+ Every action is written to `%USERPROFILE%\.coldstore\actions.jsonl`. To undo
99
+ anything:
100
+
101
+ ```powershell
102
+ coldstore journal --limit 20
103
+ coldstore undo --entry 14
104
+ ```
105
+
106
+ ## How the tiers avoid overlap
107
+
108
+ If a folder qualifies as COOL/COLD but contains a JUNK sub-folder, the
109
+ container's projection subtracts the JUNK bytes before estimating savings. The
110
+ report therefore never double-counts across tiers.
111
+
112
+ ## Safety
113
+
114
+ - Every action is journaled with before/after bytes.
115
+ - JUNK goes to the Recycle Bin (never hard-deleted).
116
+ - COLD archives are integrity-verified with `7z t` (or py7zr's `testzip`)
117
+ before the original folder is removed.
118
+ - OneDrive cloud-placeholder files are detected via `GetFileAttributesW` (real
119
+ attribute check, not a path heuristic) and excluded by default.
120
+ - Never touches `C:\Windows`, `Program Files`, `ProgramData`, or the Recycle Bin.
121
+
122
+ ## Requirements
123
+
124
+ - Windows 10 1607+ (LZX compression) or Windows 11
125
+ - Python 3.10+
126
+ - NTFS filesystem for the COOL tier
127
+
128
+ ## License
129
+
130
+ MIT
@@ -0,0 +1,97 @@
1
+ # ColdStore
2
+
3
+ Tiered laptop storage reclaimer for Windows. Point it at a folder, get an
4
+ honest, categorized plan of what can be freed, then apply the plan — every
5
+ action is journaled and reversible.
6
+
7
+ ## What it does
8
+
9
+ Three tiers, safest first:
10
+
11
+ | Tier | Trigger | Action | Reversible via |
12
+ | :--- | :--- | :--- | :--- |
13
+ | **JUNK** | Regenerable folders (`node_modules`, `.venv`, `__pycache__`, browser caches, `Temp`) or app caches | Send to Recycle Bin | Restore via Shell.Application COM |
14
+ | **COOL** | Not accessed in 30+ days, non-media content | NTFS/LZX compress in place with `compact.exe` | `compact /u /s /a /i /exe` |
15
+ | **COLD** | Not accessed in 180+ days | 7z archive; original folder replaced with a `.coldstore` stub file | `coldstore restore <stub>` |
16
+
17
+ Bonus: bit-exact **JPEG -> JPEG XL** lossless transcode using
18
+ [libjxl](https://github.com/libjxl/libjxl) — reversible to the byte-identical
19
+ original JPEG.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install coldstore
25
+ ```
26
+
27
+ Optional external tools that unlock extra performance / features:
28
+
29
+ - **[7-Zip](https://www.7-zip.org/)** — if `7z.exe` is on PATH, cold archiving
30
+ uses the native binary (much faster on large folders). Otherwise the pure-Python
31
+ `py7zr` fallback is used automatically.
32
+ - **[libjxl](https://github.com/libjxl/libjxl/releases)** — `cjxl.exe` /
33
+ `djxl.exe` on PATH enable the bit-exact JPEG -> JXL media tier.
34
+
35
+ ## Usage
36
+
37
+ ```
38
+ coldstore scan <path> # dry-run report of reclaimable space
39
+ coldstore analyze <path> [--tree] # WizTree-style disk analysis
40
+ coldstore apply <path> --junk --cool --cold # do the work
41
+ coldstore restore <stub.coldstore> # restore an archived folder
42
+ coldstore undo --last N | --entry K # undo previous actions
43
+ coldstore journal [--limit N] # timeline of everything done
44
+
45
+ coldstore gui # graphical interface
46
+ coldstore register [--uninstall] # register .coldstore file association
47
+ coldstore schedule --target <path> --cadence weekly
48
+
49
+ coldstore media scan|apply|restore <path> # bit-exact JPEG <-> JXL
50
+ ```
51
+
52
+ Recommended first run — read-only, no changes:
53
+
54
+ ```powershell
55
+ coldstore analyze C:\Users\%USERNAME% --tree --tree-depth 4 --tree-min-share 1
56
+ coldstore scan C:\Users\%USERNAME% --min-size-mb 100
57
+ ```
58
+
59
+ Then, when you're ready to actually reclaim space:
60
+
61
+ ```powershell
62
+ coldstore apply C:\Users\%USERNAME% --min-size-mb 100 --junk --cool --cold
63
+ ```
64
+
65
+ Every action is written to `%USERPROFILE%\.coldstore\actions.jsonl`. To undo
66
+ anything:
67
+
68
+ ```powershell
69
+ coldstore journal --limit 20
70
+ coldstore undo --entry 14
71
+ ```
72
+
73
+ ## How the tiers avoid overlap
74
+
75
+ If a folder qualifies as COOL/COLD but contains a JUNK sub-folder, the
76
+ container's projection subtracts the JUNK bytes before estimating savings. The
77
+ report therefore never double-counts across tiers.
78
+
79
+ ## Safety
80
+
81
+ - Every action is journaled with before/after bytes.
82
+ - JUNK goes to the Recycle Bin (never hard-deleted).
83
+ - COLD archives are integrity-verified with `7z t` (or py7zr's `testzip`)
84
+ before the original folder is removed.
85
+ - OneDrive cloud-placeholder files are detected via `GetFileAttributesW` (real
86
+ attribute check, not a path heuristic) and excluded by default.
87
+ - Never touches `C:\Windows`, `Program Files`, `ProgramData`, or the Recycle Bin.
88
+
89
+ ## Requirements
90
+
91
+ - Windows 10 1607+ (LZX compression) or Windows 11
92
+ - Python 3.10+
93
+ - NTFS filesystem for the COOL tier
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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())
@@ -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
+ ))