@mison/wecom-cleaner 1.0.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 +323 -0
- package/docs/IMPLEMENTATION_PLAN.md +162 -0
- package/docs/releases/v1.0.0.md +32 -0
- package/native/README.md +9 -0
- package/native/bin/darwin-arm64/wecom-cleaner-core +0 -0
- package/native/bin/darwin-x64/wecom-cleaner-core +0 -0
- package/native/manifest.json +15 -0
- package/native/zig/build.sh +68 -0
- package/native/zig/src/main.zig +96 -0
- package/package.json +62 -0
- package/src/analysis.js +58 -0
- package/src/cleanup.js +217 -0
- package/src/cli.js +2619 -0
- package/src/config.js +270 -0
- package/src/constants.js +309 -0
- package/src/doctor.js +366 -0
- package/src/error-taxonomy.js +73 -0
- package/src/lock.js +102 -0
- package/src/native-bridge.js +403 -0
- package/src/recycle-maintenance.js +335 -0
- package/src/restore.js +533 -0
- package/src/scanner.js +1277 -0
- package/src/utils.js +365 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { listRestorableBatches } from './restore.js';
|
|
4
|
+
import { appendJsonLine, calculateDirectorySize, ensureDir } from './utils.js';
|
|
5
|
+
import { classifyErrorType, ERROR_TYPES } from './error-taxonomy.js';
|
|
6
|
+
|
|
7
|
+
const GB = 1024 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function normalizePositiveInt(rawValue, fallbackValue, minValue = 1) {
|
|
10
|
+
const num = Number.parseInt(String(rawValue ?? ''), 10);
|
|
11
|
+
if (!Number.isFinite(num) || num < minValue) {
|
|
12
|
+
return fallbackValue;
|
|
13
|
+
}
|
|
14
|
+
return num;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeRecycleRetention(input, fallback = {}) {
|
|
18
|
+
const source = input && typeof input === 'object' ? input : {};
|
|
19
|
+
const fallbackEnabled = typeof fallback.enabled === 'boolean' ? fallback.enabled : true;
|
|
20
|
+
const fallbackMaxAgeDays = normalizePositiveInt(fallback.maxAgeDays, 30);
|
|
21
|
+
const fallbackMinKeepBatches = normalizePositiveInt(fallback.minKeepBatches, 20);
|
|
22
|
+
const fallbackSizeThresholdGB = normalizePositiveInt(fallback.sizeThresholdGB, 20);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
enabled: typeof source.enabled === 'boolean' ? source.enabled : fallbackEnabled,
|
|
26
|
+
maxAgeDays: normalizePositiveInt(source.maxAgeDays, fallbackMaxAgeDays),
|
|
27
|
+
minKeepBatches: normalizePositiveInt(source.minKeepBatches, fallbackMinKeepBatches),
|
|
28
|
+
sizeThresholdGB: normalizePositiveInt(source.sizeThresholdGB, fallbackSizeThresholdGB),
|
|
29
|
+
lastRunAt: Number.isFinite(Number(source.lastRunAt))
|
|
30
|
+
? Number(source.lastRunAt)
|
|
31
|
+
: Number(fallback.lastRunAt || 0),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bytesThreshold(policy) {
|
|
36
|
+
return Math.max(1, Number(policy.sizeThresholdGB || 20)) * GB;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ageDays(tsMillis, nowMillis) {
|
|
40
|
+
const delta = Math.max(0, Number(nowMillis) - Number(tsMillis || 0));
|
|
41
|
+
return Math.floor(delta / (24 * 3600 * 1000));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isPathWithinRoot(rootPath, targetPath) {
|
|
45
|
+
const rootAbs = path.resolve(String(rootPath || ''));
|
|
46
|
+
const targetAbs = path.resolve(String(targetPath || ''));
|
|
47
|
+
const rel = path.relative(rootAbs, targetAbs);
|
|
48
|
+
if (!rel) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveBatchRootFromEntries(recycleRoot, batch) {
|
|
55
|
+
const recycleRootAbs = path.resolve(String(recycleRoot || ''));
|
|
56
|
+
const entries = Array.isArray(batch?.entries) ? batch.entries : [];
|
|
57
|
+
if (entries.length === 0) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
invalidReason: 'empty_batch_entries',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const rootSet = new Set();
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
const recyclePath = String(entry?.recyclePath || '').trim();
|
|
67
|
+
if (!recyclePath) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
invalidReason: 'invalid_recycle_path',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const recyclePathAbs = path.resolve(recyclePath);
|
|
75
|
+
if (!isPathWithinRoot(recycleRootAbs, recyclePathAbs)) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
invalidReason: 'recycle_path_outside_recycle_root',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const batchRootAbs = path.dirname(recyclePathAbs);
|
|
83
|
+
if (!isPathWithinRoot(recycleRootAbs, batchRootAbs)) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
invalidReason: 'batch_root_outside_recycle_root',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (batchRootAbs === recycleRootAbs) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
invalidReason: 'batch_root_is_recycle_root',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
rootSet.add(batchRootAbs);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (rootSet.size !== 1) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
invalidReason: 'inconsistent_batch_roots',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
batchRoot: [...rootSet][0],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function collectRecycleStats({ indexPath, recycleRoot, createIfMissing = true }) {
|
|
112
|
+
if (createIfMissing) {
|
|
113
|
+
await ensureDir(recycleRoot);
|
|
114
|
+
} else {
|
|
115
|
+
const recycleExists = await fs
|
|
116
|
+
.stat(recycleRoot)
|
|
117
|
+
.then((stat) => stat.isDirectory())
|
|
118
|
+
.catch(() => false);
|
|
119
|
+
if (!recycleExists) {
|
|
120
|
+
return {
|
|
121
|
+
batches: [],
|
|
122
|
+
totalBatches: 0,
|
|
123
|
+
totalBytes: 0,
|
|
124
|
+
indexedBytes: 0,
|
|
125
|
+
oldestTime: null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const batches = await listRestorableBatches(indexPath, { recycleRoot });
|
|
131
|
+
const totalBatches = batches.length;
|
|
132
|
+
const indexedBytes = batches.reduce((acc, batch) => acc + Number(batch.totalBytes || 0), 0);
|
|
133
|
+
const totalBytes = await calculateDirectorySize(recycleRoot);
|
|
134
|
+
const oldestTime =
|
|
135
|
+
totalBatches > 0 ? Math.min(...batches.map((item) => Number(item.firstTime || Date.now()))) : null;
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
batches,
|
|
139
|
+
totalBatches,
|
|
140
|
+
totalBytes,
|
|
141
|
+
indexedBytes,
|
|
142
|
+
oldestTime,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function selectBatchesForMaintenance(batches, policy, now = Date.now()) {
|
|
147
|
+
const normalized = [...(Array.isArray(batches) ? batches : [])].sort((a, b) => b.firstTime - a.firstTime);
|
|
148
|
+
const minKeep = Math.max(0, Number(policy.minKeepBatches || 0));
|
|
149
|
+
const maxAge = Math.max(1, Number(policy.maxAgeDays || 30));
|
|
150
|
+
const thresholdBytes = bytesThreshold(policy);
|
|
151
|
+
const totalBytes = normalized.reduce((acc, batch) => acc + Number(batch.totalBytes || 0), 0);
|
|
152
|
+
|
|
153
|
+
const keepRecent = normalized.slice(0, minKeep);
|
|
154
|
+
const keepSet = new Set(keepRecent.map((item) => item.batchId));
|
|
155
|
+
const candidateMap = new Map();
|
|
156
|
+
|
|
157
|
+
const ageCandidates = normalized.filter((batch) => {
|
|
158
|
+
if (keepSet.has(batch.batchId)) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
return ageDays(batch.firstTime, now) >= maxAge;
|
|
162
|
+
});
|
|
163
|
+
for (const batch of ageCandidates) {
|
|
164
|
+
candidateMap.set(batch.batchId, {
|
|
165
|
+
...batch,
|
|
166
|
+
selectedBy: 'age',
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let estimatedAfterBytes =
|
|
171
|
+
totalBytes - [...candidateMap.values()].reduce((acc, batch) => acc + Number(batch.totalBytes || 0), 0);
|
|
172
|
+
|
|
173
|
+
if (estimatedAfterBytes > thresholdBytes) {
|
|
174
|
+
const extraBySize = normalized
|
|
175
|
+
.filter((batch) => !keepSet.has(batch.batchId) && !candidateMap.has(batch.batchId))
|
|
176
|
+
.sort((a, b) => a.firstTime - b.firstTime);
|
|
177
|
+
|
|
178
|
+
for (const batch of extraBySize) {
|
|
179
|
+
candidateMap.set(batch.batchId, {
|
|
180
|
+
...batch,
|
|
181
|
+
selectedBy: 'size',
|
|
182
|
+
});
|
|
183
|
+
estimatedAfterBytes -= Number(batch.totalBytes || 0);
|
|
184
|
+
if (estimatedAfterBytes <= thresholdBytes) {
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const candidates = [...candidateMap.values()].sort((a, b) => b.firstTime - a.firstTime);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
keepRecent,
|
|
194
|
+
candidates,
|
|
195
|
+
totalBytes,
|
|
196
|
+
thresholdBytes,
|
|
197
|
+
estimatedAfterBytes: Math.max(0, estimatedAfterBytes),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function maintainRecycleBin({ indexPath, recycleRoot, policy, dryRun, onProgress }) {
|
|
202
|
+
const normalizedPolicy = normalizeRecycleRetention(policy);
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
const before = await collectRecycleStats({ indexPath, recycleRoot });
|
|
205
|
+
const selected = selectBatchesForMaintenance(before.batches, normalizedPolicy, now);
|
|
206
|
+
const thresholdBytes = selected.thresholdBytes;
|
|
207
|
+
|
|
208
|
+
const summary = {
|
|
209
|
+
dryRun: Boolean(dryRun),
|
|
210
|
+
policy: normalizedPolicy,
|
|
211
|
+
before,
|
|
212
|
+
thresholdBytes,
|
|
213
|
+
overThreshold: before.totalBytes > thresholdBytes,
|
|
214
|
+
candidateCount: selected.candidates.length,
|
|
215
|
+
selectedByAge: selected.candidates.filter((item) => item.selectedBy === 'age').length,
|
|
216
|
+
selectedBySize: selected.candidates.filter((item) => item.selectedBy === 'size').length,
|
|
217
|
+
deletedBatches: 0,
|
|
218
|
+
deletedBytes: 0,
|
|
219
|
+
failBatches: 0,
|
|
220
|
+
errors: [],
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
if (!normalizedPolicy.enabled) {
|
|
224
|
+
await appendJsonLine(indexPath, {
|
|
225
|
+
action: 'recycle_maintain',
|
|
226
|
+
time: now,
|
|
227
|
+
status: 'skipped_disabled',
|
|
228
|
+
dryRun: Boolean(dryRun),
|
|
229
|
+
recycle_root: recycleRoot,
|
|
230
|
+
policy: normalizedPolicy,
|
|
231
|
+
before_batches: before.totalBatches,
|
|
232
|
+
before_bytes: before.totalBytes,
|
|
233
|
+
deleted_batches: 0,
|
|
234
|
+
deleted_bytes: 0,
|
|
235
|
+
remaining_batches: before.totalBatches,
|
|
236
|
+
remaining_bytes: before.totalBytes,
|
|
237
|
+
});
|
|
238
|
+
return {
|
|
239
|
+
...summary,
|
|
240
|
+
after: before,
|
|
241
|
+
status: 'skipped_disabled',
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (selected.candidates.length === 0) {
|
|
246
|
+
await appendJsonLine(indexPath, {
|
|
247
|
+
action: 'recycle_maintain',
|
|
248
|
+
time: now,
|
|
249
|
+
status: 'skipped_no_candidate',
|
|
250
|
+
dryRun: Boolean(dryRun),
|
|
251
|
+
recycle_root: recycleRoot,
|
|
252
|
+
policy: normalizedPolicy,
|
|
253
|
+
before_batches: before.totalBatches,
|
|
254
|
+
before_bytes: before.totalBytes,
|
|
255
|
+
deleted_batches: 0,
|
|
256
|
+
deleted_bytes: 0,
|
|
257
|
+
remaining_batches: before.totalBatches,
|
|
258
|
+
remaining_bytes: before.totalBytes,
|
|
259
|
+
});
|
|
260
|
+
return {
|
|
261
|
+
...summary,
|
|
262
|
+
after: before,
|
|
263
|
+
status: 'skipped_no_candidate',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (let i = 0; i < selected.candidates.length; i += 1) {
|
|
268
|
+
const batch = selected.candidates[i];
|
|
269
|
+
if (typeof onProgress === 'function') {
|
|
270
|
+
onProgress(i + 1, selected.candidates.length);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const resolvedBatchRoot = resolveBatchRootFromEntries(recycleRoot, batch);
|
|
274
|
+
if (!resolvedBatchRoot.ok) {
|
|
275
|
+
summary.failBatches += 1;
|
|
276
|
+
summary.errors.push({
|
|
277
|
+
batchId: batch.batchId,
|
|
278
|
+
message: `批次路径校验失败: ${resolvedBatchRoot.invalidReason}`,
|
|
279
|
+
errorType: ERROR_TYPES.PATH_VALIDATION_FAILED,
|
|
280
|
+
invalidReason: resolvedBatchRoot.invalidReason,
|
|
281
|
+
});
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (dryRun) {
|
|
286
|
+
summary.deletedBatches += 1;
|
|
287
|
+
summary.deletedBytes += Number(batch.totalBytes || 0);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
await fs.rm(resolvedBatchRoot.batchRoot, { recursive: true, force: true });
|
|
293
|
+
summary.deletedBatches += 1;
|
|
294
|
+
summary.deletedBytes += Number(batch.totalBytes || 0);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
summary.failBatches += 1;
|
|
297
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
298
|
+
summary.errors.push({
|
|
299
|
+
batchId: batch.batchId,
|
|
300
|
+
message,
|
|
301
|
+
errorType: classifyErrorType(message),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const after = dryRun ? before : await collectRecycleStats({ indexPath, recycleRoot });
|
|
307
|
+
const status = summary.failBatches > 0 ? 'partial_failed' : dryRun ? 'dry_run' : 'success';
|
|
308
|
+
|
|
309
|
+
await appendJsonLine(indexPath, {
|
|
310
|
+
action: 'recycle_maintain',
|
|
311
|
+
time: Date.now(),
|
|
312
|
+
status,
|
|
313
|
+
dryRun: Boolean(dryRun),
|
|
314
|
+
recycle_root: recycleRoot,
|
|
315
|
+
policy: normalizedPolicy,
|
|
316
|
+
threshold_bytes: thresholdBytes,
|
|
317
|
+
over_threshold: summary.overThreshold,
|
|
318
|
+
before_batches: before.totalBatches,
|
|
319
|
+
before_bytes: before.totalBytes,
|
|
320
|
+
deleted_batches: summary.deletedBatches,
|
|
321
|
+
deleted_bytes: summary.deletedBytes,
|
|
322
|
+
failed_batches: summary.failBatches,
|
|
323
|
+
selected_by_age: summary.selectedByAge,
|
|
324
|
+
selected_by_size: summary.selectedBySize,
|
|
325
|
+
remaining_batches: after.totalBatches,
|
|
326
|
+
remaining_bytes: after.totalBytes,
|
|
327
|
+
error_type: summary.failBatches > 0 ? summary.errors[0]?.errorType || ERROR_TYPES.UNKNOWN : null,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
...summary,
|
|
332
|
+
after,
|
|
333
|
+
status,
|
|
334
|
+
};
|
|
335
|
+
}
|