deepclean-mydisk 0.12.1 → 0.13.1
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/README.md +5 -4
- package/docs/plugin/DETAIL.en.md +2 -2
- package/docs/plugin/DETAIL.zh.md +2 -2
- package/lib/client.js +653 -161
- package/lib/index.js +229 -55
- package/package.json +3 -3
- package/resources/scripts/tree.py +199 -71
|
@@ -10,6 +10,13 @@ One post-order traversal produces the root listing plus:
|
|
|
10
10
|
云盘(iCloud Drive / OneDrive / Dropbox / Google Drive)与 App 包目录(照片图库等)
|
|
11
11
|
不进入 bigFiles、也不播种下钻缓存:前者搬走会同步删除云端文件,后者移动单个文件
|
|
12
12
|
会破坏应用数据。它们仍然计入目录大小,避免空间统计失真。
|
|
13
|
+
|
|
14
|
+
口径说明:
|
|
15
|
+
- 同一个 inode 的硬链接只计一次大小、只进一条 bigFiles;重复名字以 kb=0/dedup=True
|
|
16
|
+
列出,避免一个 10MB 文件被 500 个链接放大成 5GB。
|
|
17
|
+
- 云占位文件(OneDrive 按需下载)带 REPARSE 标记,必须先按云占位归类,不能算成
|
|
18
|
+
「链接」。
|
|
19
|
+
- Windows 上 >260 字符的路径用 `\\\\?\\` 前缀访问,输出仍是常规路径。
|
|
13
20
|
"""
|
|
14
21
|
import heapq
|
|
15
22
|
import json
|
|
@@ -26,6 +33,7 @@ DIR_BIG = 20 # top files kept per recorded directory
|
|
|
26
33
|
BIG_FLOOR = 1024 * 1024 # files below 1 MB never enter the big-file heaps
|
|
27
34
|
REPARSE = 0x400 # FILE_ATTRIBUTE_REPARSE_POINT (Windows)
|
|
28
35
|
CLOUD_ATTRS = 0x400000 | 0x40000 | 0x1000 # RECALL_ON_DATA_ACCESS | RECALL_ON_OPEN | OFFLINE
|
|
36
|
+
WIN_LONG_THRESHOLD = 248 # leave headroom below MAX_PATH for child names
|
|
29
37
|
|
|
30
38
|
PACKAGE_SUFFIXES = (
|
|
31
39
|
".photoslibrary", ".photolibrary", ".sparsebundle", ".fcpbundle", ".logicx",
|
|
@@ -68,7 +76,7 @@ def cloud_roots():
|
|
|
68
76
|
|
|
69
77
|
def norm_path(path):
|
|
70
78
|
"""同时给出 abspath 与 realpath 两种形态:临时目录(/var → /private/var)与
|
|
71
|
-
|
|
79
|
+
云盘根可能因符号链接前缀不同而比不中。仅用于单个路径;遍历内部走 _Matcher。"""
|
|
72
80
|
raw = os.path.normcase(os.path.abspath(str(path)))
|
|
73
81
|
try:
|
|
74
82
|
real = os.path.normcase(os.path.realpath(str(path)))
|
|
@@ -77,17 +85,45 @@ def norm_path(path):
|
|
|
77
85
|
return {raw, real}
|
|
78
86
|
|
|
79
87
|
|
|
88
|
+
def _raw(path):
|
|
89
|
+
return os.path.normcase(os.path.abspath(str(path)))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _form(path):
|
|
93
|
+
raw = _raw(path)
|
|
94
|
+
try:
|
|
95
|
+
real = os.path.normcase(os.path.realpath(str(path)))
|
|
96
|
+
except OSError:
|
|
97
|
+
real = raw
|
|
98
|
+
return raw, real
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _under(path, base):
|
|
102
|
+
if path == base:
|
|
103
|
+
return True
|
|
104
|
+
prefix = base if base.endswith(os.sep) else base + os.sep
|
|
105
|
+
return path.startswith(prefix)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _Matcher:
|
|
109
|
+
"""预归一化的根目录集合:遍历时每个条目只做字符串比较,不再逐条 realpath。
|
|
110
|
+
|
|
111
|
+
根目录的 raw/real 形态各算一次,命中判断同时比较两种形态,保留
|
|
112
|
+
「/var → /private/var」这类符号链接前缀的旧语义。
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, roots):
|
|
116
|
+
self.roots = [_form(root) for root in roots]
|
|
117
|
+
|
|
118
|
+
def hit(self, raw, real):
|
|
119
|
+
for base_raw, base_real in self.roots:
|
|
120
|
+
if _under(raw, base_raw) or _under(real, base_real):
|
|
121
|
+
return True
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
80
125
|
def is_cloud_path(path, roots):
|
|
81
|
-
|
|
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
|
|
126
|
+
return _Matcher(roots).hit(*_form(path))
|
|
91
127
|
|
|
92
128
|
|
|
93
129
|
def is_cloud_placeholder(st):
|
|
@@ -99,115 +135,207 @@ def is_package_dir(name):
|
|
|
99
135
|
return any(low.endswith(suffix) for suffix in PACKAGE_SUFFIXES)
|
|
100
136
|
|
|
101
137
|
|
|
102
|
-
def
|
|
103
|
-
|
|
138
|
+
def app_data_roots():
|
|
139
|
+
home = Path(os.environ.get("HOME") or Path.home())
|
|
140
|
+
return [home / "AppData"] + [Path(os.environ[k]) for k in ("LOCALAPPDATA", "APPDATA") if os.environ.get(k)]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def is_app_data_path(path, matcher=None, raw=None, real=None):
|
|
144
|
+
raw = raw or _raw(path)
|
|
145
|
+
parts = [p.lower() for p in raw.split(os.sep) if p]
|
|
104
146
|
pairs = set(zip(parts, parts[1:]))
|
|
105
147
|
if pairs & {("library", "application support"), ("library", "containers"),
|
|
106
148
|
("library", "group containers"), ("library", "mail"), ("library", "messages")}:
|
|
107
149
|
return True
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
150
|
+
if "windowsapps" in parts:
|
|
151
|
+
return True
|
|
152
|
+
if real is None:
|
|
153
|
+
real = _form(path)[1]
|
|
154
|
+
return (matcher or _Matcher(app_data_roots())).hit(raw, real)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def win_long(path):
|
|
158
|
+
"""Windows 上 >260 的路径需要 \\\\?\\ 前缀;只用于访问,输出仍是常规路径。"""
|
|
159
|
+
if os.name != "nt":
|
|
160
|
+
return str(path)
|
|
161
|
+
absolute = os.path.abspath(str(path))
|
|
162
|
+
if absolute.startswith("\\\\?\\"):
|
|
163
|
+
return absolute
|
|
164
|
+
return absolute if len(absolute) < WIN_LONG_THRESHOLD else "\\\\?\\" + absolute
|
|
111
165
|
|
|
112
166
|
|
|
113
167
|
def inspect_tree(target):
|
|
114
168
|
logical = os.name == 'nt'
|
|
115
169
|
dirs = {}
|
|
116
170
|
clouds = cloud_roots()
|
|
117
|
-
|
|
171
|
+
cloud_matcher = _Matcher(clouds)
|
|
172
|
+
app_matcher = _Matcher(app_data_roots())
|
|
173
|
+
target_raw = _raw(target)
|
|
174
|
+
try:
|
|
175
|
+
target_real = os.path.normcase(os.path.realpath(str(target)))
|
|
176
|
+
except OSError:
|
|
177
|
+
target_real = target_raw
|
|
178
|
+
seen_inodes = set()
|
|
179
|
+
|
|
180
|
+
def forms(path):
|
|
181
|
+
"""遍历内路径的 (raw, real):目标之外才 realpath;目标内用前缀映射,避免逐条系统调用。"""
|
|
182
|
+
raw = _raw(path)
|
|
183
|
+
if _under(raw, target_raw):
|
|
184
|
+
suffix = raw[len(target_raw):]
|
|
185
|
+
return raw, target_real + suffix
|
|
186
|
+
return _form(path)
|
|
118
187
|
|
|
119
|
-
def push(heap, size_bytes, file_path, mtime):
|
|
188
|
+
def push(heap, size_bytes, file_path, mtime, shared=False):
|
|
120
189
|
if size_bytes < BIG_FLOOR:
|
|
121
190
|
return
|
|
122
|
-
item = (size_bytes, file_path, mtime)
|
|
191
|
+
item = (size_bytes, file_path, mtime, shared)
|
|
123
192
|
if len(heap) < ROOT_BIG:
|
|
124
193
|
heapq.heappush(heap, item)
|
|
125
194
|
elif item > heap[0]:
|
|
126
195
|
heapq.heapreplace(heap, item)
|
|
127
196
|
|
|
128
197
|
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]]
|
|
198
|
+
return [dict(path=p, kb=b / 1024, mtime=m, shared=s)
|
|
199
|
+
for b, p, m, s in sorted(heap, reverse=True)[:k]]
|
|
131
200
|
|
|
132
|
-
def walk(dir_path, hidden=False):
|
|
133
|
-
"""Return
|
|
201
|
+
def walk(dir_path, hidden=False, in_cloud=False):
|
|
202
|
+
"""Return a dict with total/heap/unavailable/skipped/cloud/cloud_files/shared/children.
|
|
134
203
|
|
|
135
|
-
`hidden`
|
|
204
|
+
`hidden` 表示当前子树位于云盘/包目录/App 数据内:照常统计大小,但不产出 bigFiles、
|
|
136
205
|
不播种下钻缓存(避免被搬家/去重流程选中)。
|
|
137
206
|
"""
|
|
138
|
-
nonlocal skipped_cloud
|
|
139
207
|
children = []
|
|
140
208
|
heap = []
|
|
141
209
|
total = 0
|
|
142
210
|
unavailable = 0
|
|
143
211
|
skipped = 0
|
|
212
|
+
cloud = 0
|
|
213
|
+
cloud_files = 0
|
|
214
|
+
shared = 0
|
|
215
|
+
shared_dup = 0
|
|
144
216
|
try:
|
|
145
|
-
entries = list(os.scandir(dir_path))
|
|
217
|
+
entries = list(os.scandir(win_long(dir_path)))
|
|
146
218
|
except OSError:
|
|
147
|
-
return 0, heap, 1, 0,
|
|
219
|
+
return dict(total=0, heap=heap, unavailable=1, skipped=0, cloud=0,
|
|
220
|
+
cloud_files=0, shared=0, shared_dup=0, children=None)
|
|
148
221
|
for e in entries:
|
|
222
|
+
child = os.path.join(dir_path, e.name)
|
|
149
223
|
try:
|
|
150
224
|
st = e.stat(follow_symlinks=False)
|
|
151
225
|
except OSError:
|
|
152
226
|
unavailable += 1
|
|
153
227
|
continue
|
|
154
|
-
|
|
228
|
+
reparse = bool(getattr(st, 'st_file_attributes', 0) & REPARSE)
|
|
229
|
+
if stat.S_ISLNK(st.st_mode):
|
|
155
230
|
skipped += 1
|
|
156
231
|
continue
|
|
157
232
|
if stat.S_ISDIR(st.st_mode):
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
#
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
233
|
+
raw, real = forms(child)
|
|
234
|
+
if cloud_matcher.hit(raw, real):
|
|
235
|
+
# 云盘根/子目录:只计大小,不进入大文件清单、不播种下钻缓存。
|
|
236
|
+
if not in_cloud:
|
|
237
|
+
cloud += 1
|
|
238
|
+
sub = walk(child, True, True)
|
|
239
|
+
total += sub['total']
|
|
240
|
+
unavailable += sub['unavailable']
|
|
241
|
+
skipped += sub['skipped']
|
|
242
|
+
cloud_files += sub['cloud_files']
|
|
243
|
+
shared += sub['shared']
|
|
244
|
+
shared_dup += sub['shared_dup']
|
|
245
|
+
children.append(dict(name=e.name, path=child, kb=sub['total'] / 1024,
|
|
246
|
+
isDir=True, partial=sub['unavailable'] > 0, cloud=True))
|
|
167
247
|
continue
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
248
|
+
if reparse:
|
|
249
|
+
# 云占位目录先归类为云盘,不能当成普通链接;不遍历(可能是 reparse 环)。
|
|
250
|
+
if is_cloud_placeholder(st):
|
|
251
|
+
if not in_cloud:
|
|
252
|
+
cloud += 1
|
|
253
|
+
children.append(dict(name=e.name, path=child, kb=0, isDir=True,
|
|
254
|
+
partial=True, cloud=True))
|
|
255
|
+
else:
|
|
256
|
+
skipped += 1
|
|
257
|
+
continue
|
|
258
|
+
hidden_child = (hidden or is_package_dir(e.name)
|
|
259
|
+
or is_app_data_path(child, app_matcher, raw, real))
|
|
260
|
+
sub = walk(child, hidden_child, False)
|
|
261
|
+
if sub['children'] is None:
|
|
171
262
|
unavailable += 1
|
|
172
|
-
children.append(dict(name=e.name, path=
|
|
263
|
+
children.append(dict(name=e.name, path=child, kb=0, isDir=True, partial=True))
|
|
173
264
|
continue
|
|
174
|
-
total +=
|
|
175
|
-
unavailable +=
|
|
176
|
-
skipped +=
|
|
265
|
+
total += sub['total']
|
|
266
|
+
unavailable += sub['unavailable']
|
|
267
|
+
skipped += sub['skipped']
|
|
268
|
+
cloud += sub['cloud']
|
|
269
|
+
cloud_files += sub['cloud_files']
|
|
270
|
+
shared += sub['shared']
|
|
271
|
+
shared_dup += sub['shared_dup']
|
|
177
272
|
if not hidden_child:
|
|
178
|
-
for
|
|
179
|
-
push(heap,
|
|
180
|
-
children.append(dict(name=e.name, path=
|
|
181
|
-
isDir=True, partial=
|
|
182
|
-
if
|
|
183
|
-
dirs[
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
273
|
+
for item in sub['heap']:
|
|
274
|
+
push(heap, *item)
|
|
275
|
+
children.append(dict(name=e.name, path=child, kb=sub['total'] / 1024,
|
|
276
|
+
isDir=True, partial=sub['unavailable'] > 0))
|
|
277
|
+
if sub['total'] >= MIN_DIR_KB * 1024 and not hidden_child:
|
|
278
|
+
dirs[child] = dict(
|
|
279
|
+
kb=sub['total'] / 1024,
|
|
280
|
+
children=sorted(sub['children'], key=lambda c: -c['kb']),
|
|
281
|
+
unavailable=sub['unavailable'], skipped=sub['skipped'],
|
|
282
|
+
skippedCloud=sub['cloud'], cloudFiles=sub['cloud_files'],
|
|
283
|
+
sharedFiles=sub['shared'], sharedDupKb=sub['shared_dup'] / 1024,
|
|
284
|
+
bigFilesCap=DIR_BIG,
|
|
285
|
+
bigFiles=top(sub['heap'], DIR_BIG))
|
|
286
|
+
continue
|
|
287
|
+
# file
|
|
288
|
+
if is_cloud_placeholder(st):
|
|
289
|
+
cloud_files += 1
|
|
290
|
+
continue
|
|
291
|
+
size_b = st.st_size if logical else getattr(st, 'st_blocks', 0) * 512
|
|
292
|
+
nlink = getattr(st, 'st_nlink', 1)
|
|
293
|
+
ino = getattr(st, 'st_ino', 0)
|
|
294
|
+
dev = getattr(st, 'st_dev', 0)
|
|
295
|
+
# Windows 的 DirEntry.stat() 不填 nlink/ino(都是 0);只对可能进 bigFiles
|
|
296
|
+
# 的大文件补一次完整 stat,避免在每个文件上多一次系统调用。
|
|
297
|
+
if (not nlink or not ino) and size_b >= BIG_FLOOR:
|
|
298
|
+
try:
|
|
299
|
+
full = os.stat(child)
|
|
300
|
+
nlink, ino, dev = full.st_nlink, full.st_ino, full.st_dev
|
|
301
|
+
except OSError:
|
|
302
|
+
nlink, ino = 1, 0
|
|
303
|
+
key = (dev, ino) if nlink > 1 and ino else None
|
|
304
|
+
is_shared = key is not None
|
|
305
|
+
if is_shared:
|
|
306
|
+
shared += 1
|
|
307
|
+
if key in seen_inodes:
|
|
308
|
+
shared_dup += size_b
|
|
309
|
+
children.append(dict(name=e.name, path=child, kb=0.0, isDir=False,
|
|
310
|
+
partial=False, shared=True, dedup=True))
|
|
190
311
|
continue
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
return total, heap, unavailable, skipped,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
312
|
+
seen_inodes.add(key)
|
|
313
|
+
total += size_b
|
|
314
|
+
if not hidden:
|
|
315
|
+
push(heap, size_b, child, int(st.st_mtime), is_shared)
|
|
316
|
+
children.append(dict(name=e.name, path=child, kb=size_b / 1024,
|
|
317
|
+
isDir=False, partial=False, shared=is_shared))
|
|
318
|
+
return dict(total=total, heap=heap, unavailable=unavailable, skipped=skipped,
|
|
319
|
+
cloud=cloud, cloud_files=cloud_files, shared=shared,
|
|
320
|
+
shared_dup=shared_dup, children=children)
|
|
321
|
+
|
|
322
|
+
root_cloud = cloud_matcher.hit(*forms(target))
|
|
323
|
+
root_hidden = (is_app_data_path(target, app_matcher)
|
|
324
|
+
or any(is_package_dir(p) for p in Path(target).parts)
|
|
325
|
+
or root_cloud)
|
|
326
|
+
root = walk(target, root_hidden, root_cloud)
|
|
327
|
+
if root['children'] is None:
|
|
202
328
|
return None
|
|
203
329
|
if len(dirs) > MAX_DIRS:
|
|
204
330
|
keep = sorted(dirs, key=lambda p: -dirs[p]['kb'])[:MAX_DIRS]
|
|
205
331
|
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,
|
|
208
|
-
skippedCloud=
|
|
332
|
+
return dict(path=target, children=sorted(root['children'], key=lambda c: -c['kb']),
|
|
333
|
+
at=int(time.time() * 1000), unavailable=root['unavailable'],
|
|
334
|
+
skipped=root['skipped'], skippedCloud=root['cloud'],
|
|
335
|
+
cloudFiles=root['cloud_files'], sharedFiles=root['shared'],
|
|
336
|
+
sharedDupKb=root['shared_dup'] / 1024, bigFilesCap=ROOT_BIG,
|
|
209
337
|
metric='logical' if logical else 'allocated',
|
|
210
|
-
bigFiles=top(heap, ROOT_BIG), dirs=dirs)
|
|
338
|
+
bigFiles=top(root['heap'], ROOT_BIG), dirs=dirs)
|
|
211
339
|
|
|
212
340
|
|
|
213
341
|
if __name__ == '__main__':
|