deepclean-mydisk 0.0.0-stage → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +354 -2
- package/assets/icon-180.png +0 -0
- package/cordis.patch.yml +5 -0
- package/docs/plugin/DETAIL.en.md +58 -0
- package/docs/plugin/DETAIL.zh.md +58 -0
- package/docs/plugin/shot-clean.jpg +0 -0
- package/docs/plugin/shot-home.jpg +0 -0
- package/docs/plugin/shot-move.jpg +0 -0
- package/docs/plugin/shot-space.jpg +0 -0
- package/lib/clean.js +537 -0
- package/lib/client.js +2625 -0
- package/lib/index.js +1944 -0
- package/lib/platform.js +29 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +67 -4
- package/resources/LICENSE-phosphor.txt +21 -0
- package/resources/images/disk.png +0 -0
- package/resources/rules.json +139 -0
- package/resources/scripts/deepclean.py +109 -0
- package/resources/scripts/photos-windows.ps1 +77 -0
- package/resources/scripts/photos.py +268 -0
- package/resources/scripts/platforms/windows.py +940 -0
- package/resources/scripts/scan.py +949 -0
- package/resources/scripts/tree.py +218 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Read-only directory totals from a single walk. Never follow symlinks, junctions or reparse points.
|
|
2
|
+
|
|
3
|
+
One post-order traversal produces the root listing plus:
|
|
4
|
+
- `dirs`: per-directory listings for subtrees >= MIN_DIR_KB (capped at MAX_DIRS by
|
|
5
|
+
size), so the host can seed its tree cache for every level at once and drill-down
|
|
6
|
+
does not re-walk the subtree;
|
|
7
|
+
- `bigFiles`: top files per directory from heaps maintained during the same walk,
|
|
8
|
+
so the space page can list large files of any folder without a full-disk scan.
|
|
9
|
+
|
|
10
|
+
云盘(iCloud Drive / OneDrive / Dropbox / Google Drive)与 App 包目录(照片图库等)
|
|
11
|
+
不进入 bigFiles、也不播种下钻缓存:前者搬走会同步删除云端文件,后者移动单个文件
|
|
12
|
+
会破坏应用数据。它们仍然计入目录大小,避免空间统计失真。
|
|
13
|
+
"""
|
|
14
|
+
import heapq
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import stat
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
MAX_DIRS = 500 # recorded subdirectory listings, largest subtrees first
|
|
23
|
+
MIN_DIR_KB = 10 * 1024 # only record subtrees >= 10 MB in `dirs`
|
|
24
|
+
ROOT_BIG = 50 # top files reported for the scanned root
|
|
25
|
+
DIR_BIG = 20 # top files kept per recorded directory
|
|
26
|
+
BIG_FLOOR = 1024 * 1024 # files below 1 MB never enter the big-file heaps
|
|
27
|
+
REPARSE = 0x400 # FILE_ATTRIBUTE_REPARSE_POINT (Windows)
|
|
28
|
+
CLOUD_ATTRS = 0x400000 | 0x40000 | 0x1000 # RECALL_ON_DATA_ACCESS | RECALL_ON_OPEN | OFFLINE
|
|
29
|
+
|
|
30
|
+
PACKAGE_SUFFIXES = (
|
|
31
|
+
".photoslibrary", ".photolibrary", ".sparsebundle", ".fcpbundle", ".logicx",
|
|
32
|
+
".band", ".imovielibrary", ".tvlibrary", ".aplibrary",
|
|
33
|
+
)
|
|
34
|
+
CLOUD_DIR_NAMES = ("onedrive", "dropbox", "google drive", "icloud drive")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cloud_roots():
|
|
38
|
+
"""云盘根目录:平台固定位置 + Windows 已知环境变量/目录 + DEEPCLEAN_CLOUD_ROOTS(宿主注入)。"""
|
|
39
|
+
roots = []
|
|
40
|
+
home = Path.home()
|
|
41
|
+
if sys.platform == "darwin":
|
|
42
|
+
roots += [home / "Library" / "CloudStorage", home / "Library" / "Mobile Documents"]
|
|
43
|
+
else:
|
|
44
|
+
for name in ("OneDrive", "OneDriveConsumer", "OneDriveCommercial"):
|
|
45
|
+
value = os.environ.get(name)
|
|
46
|
+
if value:
|
|
47
|
+
roots.append(Path(value))
|
|
48
|
+
try:
|
|
49
|
+
for child in home.iterdir():
|
|
50
|
+
if child.name.lower().startswith(CLOUD_DIR_NAMES):
|
|
51
|
+
roots.append(child)
|
|
52
|
+
except OSError:
|
|
53
|
+
pass
|
|
54
|
+
extra = os.environ.get("DEEPCLEAN_CLOUD_ROOTS")
|
|
55
|
+
if extra:
|
|
56
|
+
try:
|
|
57
|
+
roots += [Path(p) for p in json.loads(extra) if isinstance(p, str)]
|
|
58
|
+
except (ValueError, TypeError):
|
|
59
|
+
pass
|
|
60
|
+
out = []
|
|
61
|
+
for root in roots:
|
|
62
|
+
try:
|
|
63
|
+
out.append(str(root.resolve()))
|
|
64
|
+
except OSError:
|
|
65
|
+
continue
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def norm_path(path):
|
|
70
|
+
"""同时给出 abspath 与 realpath 两种形态:临时目录(/var → /private/var)与
|
|
71
|
+
云盘根可能因符号链接前缀不同而比不中。"""
|
|
72
|
+
raw = os.path.normcase(os.path.abspath(str(path)))
|
|
73
|
+
try:
|
|
74
|
+
real = os.path.normcase(os.path.realpath(str(path)))
|
|
75
|
+
except OSError:
|
|
76
|
+
real = raw
|
|
77
|
+
return {raw, real}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_cloud_path(path, roots):
|
|
81
|
+
targets = norm_path(path)
|
|
82
|
+
for root in roots:
|
|
83
|
+
bases = norm_path(root)
|
|
84
|
+
if targets & bases:
|
|
85
|
+
return True
|
|
86
|
+
for target in targets:
|
|
87
|
+
for base in bases:
|
|
88
|
+
if target == base or target.startswith(base + os.sep):
|
|
89
|
+
return True
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_cloud_placeholder(st):
|
|
94
|
+
return bool(getattr(st, "st_file_attributes", 0) & CLOUD_ATTRS)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_package_dir(name):
|
|
98
|
+
low = name.lower()
|
|
99
|
+
return any(low.endswith(suffix) for suffix in PACKAGE_SUFFIXES)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_app_data_path(path):
|
|
103
|
+
parts = [p.lower() for p in Path(os.path.abspath(str(path))).parts]
|
|
104
|
+
pairs = set(zip(parts, parts[1:]))
|
|
105
|
+
if pairs & {("library", "application support"), ("library", "containers"),
|
|
106
|
+
("library", "group containers"), ("library", "mail"), ("library", "messages")}:
|
|
107
|
+
return True
|
|
108
|
+
home = Path(os.environ.get("HOME") or Path.home())
|
|
109
|
+
roots = [home / "AppData"] + [Path(os.environ[k]) for k in ("LOCALAPPDATA", "APPDATA") if os.environ.get(k)]
|
|
110
|
+
return "windowsapps" in parts or is_cloud_path(path, roots)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def inspect_tree(target):
|
|
114
|
+
logical = os.name == 'nt'
|
|
115
|
+
dirs = {}
|
|
116
|
+
clouds = cloud_roots()
|
|
117
|
+
skipped_cloud = 0
|
|
118
|
+
|
|
119
|
+
def push(heap, size_bytes, file_path, mtime):
|
|
120
|
+
if size_bytes < BIG_FLOOR:
|
|
121
|
+
return
|
|
122
|
+
item = (size_bytes, file_path, mtime)
|
|
123
|
+
if len(heap) < ROOT_BIG:
|
|
124
|
+
heapq.heappush(heap, item)
|
|
125
|
+
elif item > heap[0]:
|
|
126
|
+
heapq.heapreplace(heap, item)
|
|
127
|
+
|
|
128
|
+
def top(heap, k):
|
|
129
|
+
return [dict(path=p, kb=b / 1024, mtime=m)
|
|
130
|
+
for b, p, m in sorted(heap, reverse=True)[:k]]
|
|
131
|
+
|
|
132
|
+
def walk(dir_path, hidden=False):
|
|
133
|
+
"""Return (total_bytes, heap, unavailable, skipped, children).
|
|
134
|
+
|
|
135
|
+
`hidden` 表示当前子树位于云盘/包目录内:照常统计大小,但不产出 bigFiles、
|
|
136
|
+
不播种下钻缓存(避免被搬家/去重流程选中)。
|
|
137
|
+
"""
|
|
138
|
+
nonlocal skipped_cloud
|
|
139
|
+
children = []
|
|
140
|
+
heap = []
|
|
141
|
+
total = 0
|
|
142
|
+
unavailable = 0
|
|
143
|
+
skipped = 0
|
|
144
|
+
try:
|
|
145
|
+
entries = list(os.scandir(dir_path))
|
|
146
|
+
except OSError:
|
|
147
|
+
return 0, heap, 1, 0, None
|
|
148
|
+
for e in entries:
|
|
149
|
+
try:
|
|
150
|
+
st = e.stat(follow_symlinks=False)
|
|
151
|
+
except OSError:
|
|
152
|
+
unavailable += 1
|
|
153
|
+
continue
|
|
154
|
+
if stat.S_ISLNK(st.st_mode) or getattr(st, 'st_file_attributes', 0) & REPARSE:
|
|
155
|
+
skipped += 1
|
|
156
|
+
continue
|
|
157
|
+
if stat.S_ISDIR(st.st_mode):
|
|
158
|
+
if is_cloud_path(e.path, clouds):
|
|
159
|
+
skipped_cloud += 1
|
|
160
|
+
# 云盘目录只计大小,不进入大文件清单/下钻缓存。
|
|
161
|
+
size_b, _sub_heap, sub_un, sub_sk, _sub_children = walk(e.path, True)
|
|
162
|
+
total += size_b
|
|
163
|
+
unavailable += sub_un
|
|
164
|
+
skipped += sub_sk
|
|
165
|
+
children.append(dict(name=e.name, path=e.path, kb=size_b / 1024,
|
|
166
|
+
isDir=True, partial=sub_un > 0))
|
|
167
|
+
continue
|
|
168
|
+
hidden_child = hidden or is_package_dir(e.name) or is_app_data_path(e.path)
|
|
169
|
+
size_b, sub_heap, sub_un, sub_sk, sub_children = walk(e.path, hidden_child)
|
|
170
|
+
if sub_children is None:
|
|
171
|
+
unavailable += 1
|
|
172
|
+
children.append(dict(name=e.name, path=e.path, kb=0, isDir=True, partial=True))
|
|
173
|
+
continue
|
|
174
|
+
total += size_b
|
|
175
|
+
unavailable += sub_un
|
|
176
|
+
skipped += sub_sk
|
|
177
|
+
if not hidden_child:
|
|
178
|
+
for size_i, path_i, mtime_i in sub_heap:
|
|
179
|
+
push(heap, size_i, path_i, mtime_i)
|
|
180
|
+
children.append(dict(name=e.name, path=e.path, kb=size_b / 1024,
|
|
181
|
+
isDir=True, partial=sub_un > 0))
|
|
182
|
+
if size_b >= MIN_DIR_KB * 1024 and not hidden_child:
|
|
183
|
+
dirs[e.path] = dict(kb=size_b / 1024,
|
|
184
|
+
children=sorted(sub_children, key=lambda c: -c['kb']),
|
|
185
|
+
unavailable=sub_un, skipped=sub_sk,
|
|
186
|
+
bigFiles=top(sub_heap, DIR_BIG))
|
|
187
|
+
else:
|
|
188
|
+
if is_cloud_placeholder(st):
|
|
189
|
+
skipped_cloud += 1
|
|
190
|
+
continue
|
|
191
|
+
size_b = st.st_size if logical else getattr(st, 'st_blocks', 0) * 512
|
|
192
|
+
total += size_b
|
|
193
|
+
if not hidden:
|
|
194
|
+
push(heap, size_b, e.path, int(st.st_mtime))
|
|
195
|
+
children.append(dict(name=e.name, path=e.path, kb=size_b / 1024,
|
|
196
|
+
isDir=False, partial=False))
|
|
197
|
+
return total, heap, unavailable, skipped, children
|
|
198
|
+
|
|
199
|
+
root_hidden = is_app_data_path(target) or is_cloud_path(target, clouds) or any(is_package_dir(p) for p in Path(target).parts)
|
|
200
|
+
_total, heap, unavailable, skipped, children = walk(target, root_hidden)
|
|
201
|
+
if children is None:
|
|
202
|
+
return None
|
|
203
|
+
if len(dirs) > MAX_DIRS:
|
|
204
|
+
keep = sorted(dirs, key=lambda p: -dirs[p]['kb'])[:MAX_DIRS]
|
|
205
|
+
dirs = {p: dirs[p] for p in keep}
|
|
206
|
+
return dict(path=target, children=sorted(children, key=lambda c: -c['kb']),
|
|
207
|
+
at=int(time.time() * 1000), unavailable=unavailable, skipped=skipped,
|
|
208
|
+
skippedCloud=skipped_cloud,
|
|
209
|
+
metric='logical' if logical else 'allocated',
|
|
210
|
+
bigFiles=top(heap, ROOT_BIG), dirs=dirs)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == '__main__':
|
|
214
|
+
result = inspect_tree(sys.argv[1])
|
|
215
|
+
if result is None:
|
|
216
|
+
print(f'无法读取目录:{sys.argv[1]}', file=sys.stderr)
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
print(json.dumps(result, ensure_ascii=True))
|