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.
- disk_coldstore-0.1.0/LICENSE +21 -0
- disk_coldstore-0.1.0/PKG-INFO +130 -0
- disk_coldstore-0.1.0/README.md +97 -0
- disk_coldstore-0.1.0/coldstore/__init__.py +1 -0
- disk_coldstore-0.1.0/coldstore/__main__.py +240 -0
- disk_coldstore-0.1.0/coldstore/actions.py +157 -0
- disk_coldstore-0.1.0/coldstore/analyze.py +135 -0
- disk_coldstore-0.1.0/coldstore/apply.py +94 -0
- disk_coldstore-0.1.0/coldstore/archive.py +255 -0
- disk_coldstore-0.1.0/coldstore/config.py +91 -0
- disk_coldstore-0.1.0/coldstore/estimator.py +43 -0
- disk_coldstore-0.1.0/coldstore/gui.py +461 -0
- disk_coldstore-0.1.0/coldstore/integration.py +150 -0
- disk_coldstore-0.1.0/coldstore/journal.py +40 -0
- disk_coldstore-0.1.0/coldstore/media.py +283 -0
- disk_coldstore-0.1.0/coldstore/recovery.py +208 -0
- disk_coldstore-0.1.0/coldstore/report.py +211 -0
- disk_coldstore-0.1.0/coldstore/rules.py +111 -0
- disk_coldstore-0.1.0/coldstore/scanner.py +170 -0
- disk_coldstore-0.1.0/coldstore/winapi.py +58 -0
- disk_coldstore-0.1.0/disk_coldstore.egg-info/PKG-INFO +130 -0
- disk_coldstore-0.1.0/disk_coldstore.egg-info/SOURCES.txt +26 -0
- disk_coldstore-0.1.0/disk_coldstore.egg-info/dependency_links.txt +1 -0
- disk_coldstore-0.1.0/disk_coldstore.egg-info/entry_points.txt +5 -0
- disk_coldstore-0.1.0/disk_coldstore.egg-info/requires.txt +3 -0
- disk_coldstore-0.1.0/disk_coldstore.egg-info/top_level.txt +1 -0
- disk_coldstore-0.1.0/pyproject.toml +52 -0
- disk_coldstore-0.1.0/setup.cfg +4 -0
|
@@ -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
|
+
))
|