@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/src/scanner.js ADDED
@@ -0,0 +1,1277 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { spawnSync } from 'node:child_process';
5
+ import {
6
+ CACHE_CATEGORIES,
7
+ CATEGORY_MAP,
8
+ SPACE_GOVERNANCE_TARGETS,
9
+ SPACE_GOVERNANCE_TIERS,
10
+ CJK_TEXT_RE,
11
+ EMAIL_RE,
12
+ USER_LABEL_STOPWORDS,
13
+ CORP_LABEL_STOPWORDS,
14
+ } from './constants.js';
15
+ import {
16
+ calculateDirectorySize,
17
+ decodeBase64Utf8,
18
+ mapLimit,
19
+ normalizeMonthKey,
20
+ shortId,
21
+ sortMonthKeys,
22
+ inferDataRootFromProfilesRoot,
23
+ } from './utils.js';
24
+
25
+ const CATEGORY_BY_KEY = new Map(CACHE_CATEGORIES.map((item) => [item.key, item]));
26
+ const WWSECURITY_KEY = 'wwsecurity';
27
+ const EXTERNAL_STORAGE_CACHE_RELATIVE = path.join('WXWork Files', 'Caches');
28
+ const EXTERNAL_SOURCE_BUILTIN = 'builtin';
29
+ const EXTERNAL_SOURCE_CONFIGURED = 'configured';
30
+ const EXTERNAL_SOURCE_AUTO = 'auto';
31
+ const EXTERNAL_STORAGE_SCAN_MAX_DEPTH_DEFAULT = 2;
32
+ const EXTERNAL_STORAGE_SCAN_MAX_VISITS_DEFAULT = 400;
33
+ const EXTERNAL_STORAGE_CACHE_TTL_MS_DEFAULT = 15_000;
34
+ const EXTERNAL_STORAGE_SCAN_SKIP_NAMES = new Set([
35
+ '.',
36
+ '..',
37
+ 'Library',
38
+ 'Applications',
39
+ 'Movies',
40
+ 'Music',
41
+ 'Pictures',
42
+ 'node_modules',
43
+ '.Trash',
44
+ '.Trashes',
45
+ '.Spotlight-V100',
46
+ '.fseventsd',
47
+ '.TemporaryItems',
48
+ '.DocumentRevisions-V100',
49
+ ]);
50
+ const externalStorageDetectCache = new Map();
51
+
52
+ function parseBooleanEnv(rawValue, fallbackValue) {
53
+ if (typeof rawValue !== 'string') {
54
+ return fallbackValue;
55
+ }
56
+ const normalized = rawValue.trim().toLowerCase();
57
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) {
58
+ return true;
59
+ }
60
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) {
61
+ return false;
62
+ }
63
+ return fallbackValue;
64
+ }
65
+
66
+ async function readCurrentProfileId(rootDir) {
67
+ const settingPath = path.join(rootDir, 'setting.json');
68
+ try {
69
+ const raw = await fs.readFile(settingPath, 'utf-8');
70
+ const json = JSON.parse(raw);
71
+ const value = json?.CurrentProfile;
72
+ if (typeof value === 'string' && value) {
73
+ return value;
74
+ }
75
+ return null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function pickFirstEmail(text) {
82
+ const source = text || '';
83
+ for (const match of source.matchAll(EMAIL_RE)) {
84
+ const email = match[0];
85
+ if (!email) {
86
+ continue;
87
+ }
88
+ if (email.toLowerCase().endsWith('@wework.qpic.cn')) {
89
+ continue;
90
+ }
91
+ return email;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function pickFirstCjk(text, stopwords) {
97
+ const source = text || '';
98
+ for (const match of source.matchAll(CJK_TEXT_RE)) {
99
+ const token = match[0];
100
+ if (!token || stopwords.has(token)) {
101
+ continue;
102
+ }
103
+ if (token.startsWith('帮助企业') || token.startsWith('实现移动化办公')) {
104
+ continue;
105
+ }
106
+ return token;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ async function extractIdentity(profilePath) {
112
+ const ioPath = path.join(profilePath, 'io_data.json');
113
+ try {
114
+ const raw = await fs.readFile(ioPath, 'utf-8');
115
+ const json = JSON.parse(raw);
116
+ const userText = decodeBase64Utf8(json?.user_info);
117
+ const corpText = decodeBase64Utf8(json?.corp_info);
118
+
119
+ const userName = pickFirstCjk(userText, USER_LABEL_STOPWORDS) || pickFirstEmail(userText);
120
+ const corpName = pickFirstCjk(corpText, CORP_LABEL_STOPWORDS);
121
+ return {
122
+ userName: userName || null,
123
+ corpName: corpName || null,
124
+ };
125
+ } catch {
126
+ return { userName: null, corpName: null };
127
+ }
128
+ }
129
+
130
+ async function listDirectoryEntries(targetPath) {
131
+ try {
132
+ return await fs.readdir(targetPath, { withFileTypes: true });
133
+ } catch {
134
+ return [];
135
+ }
136
+ }
137
+
138
+ async function listSubDirectories(targetPath) {
139
+ const entries = await listDirectoryEntries(targetPath);
140
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
141
+ }
142
+
143
+ async function listDirectFiles(targetPath) {
144
+ const entries = await listDirectoryEntries(targetPath);
145
+ return entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
146
+ }
147
+
148
+ async function isDirectoryPath(targetPath) {
149
+ const stat = await fs.stat(targetPath).catch(() => null);
150
+ return Boolean(stat?.isDirectory());
151
+ }
152
+
153
+ function normalizeExternalStorageRootCandidate(rawPath) {
154
+ const input = String(rawPath || '').trim();
155
+ if (!input) {
156
+ return null;
157
+ }
158
+ const normalized = path.resolve(input);
159
+ const lower = normalized.toLowerCase();
160
+ const wxworkFilesSuffix = `${path.sep}wxwork files`;
161
+ const cachesSuffix = `${wxworkFilesSuffix}${path.sep}caches`;
162
+
163
+ if (lower.endsWith(cachesSuffix)) {
164
+ return path.dirname(path.dirname(normalized));
165
+ }
166
+ if (lower.endsWith(wxworkFilesSuffix)) {
167
+ return path.dirname(normalized);
168
+ }
169
+ return normalized;
170
+ }
171
+
172
+ async function resolveExternalStorageRoot(rawPath) {
173
+ const root = normalizeExternalStorageRootCandidate(rawPath);
174
+ if (!root) {
175
+ return null;
176
+ }
177
+ const cacheRoot = path.join(root, EXTERNAL_STORAGE_CACHE_RELATIVE);
178
+ if (!(await isDirectoryPath(cacheRoot))) {
179
+ return null;
180
+ }
181
+ return root;
182
+ }
183
+
184
+ function normalizeRootListForCache(rawList) {
185
+ return [
186
+ ...new Set(
187
+ (rawList || [])
188
+ .map((item) => String(item || '').trim())
189
+ .filter(Boolean)
190
+ .map((item) => path.resolve(item))
191
+ ),
192
+ ].sort();
193
+ }
194
+
195
+ function buildExternalDetectCacheKey(options = {}, resolvedAutoDetect = null) {
196
+ const configuredRoots = normalizeRootListForCache(options.configuredRoots);
197
+ const searchBaseRoots = normalizeRootListForCache(options.searchBaseRoots);
198
+ const profilesRoot = String(options.profilesRoot || '').trim()
199
+ ? path.resolve(String(options.profilesRoot || '').trim())
200
+ : '';
201
+ const autoDetect =
202
+ typeof resolvedAutoDetect === 'boolean' ? resolvedAutoDetect : options.autoDetect !== false;
203
+ const searchMaxDepth = Number(options.searchMaxDepth || EXTERNAL_STORAGE_SCAN_MAX_DEPTH_DEFAULT);
204
+ const searchVisitLimit = Number(options.searchVisitLimit || EXTERNAL_STORAGE_SCAN_MAX_VISITS_DEFAULT);
205
+ return JSON.stringify({
206
+ configuredRoots,
207
+ searchBaseRoots,
208
+ profilesRoot,
209
+ autoDetect,
210
+ searchMaxDepth,
211
+ searchVisitLimit,
212
+ });
213
+ }
214
+
215
+ function collectBuiltInStorageRootCandidates(options = {}) {
216
+ const candidates = [];
217
+ const profilesRoot = String(options.profilesRoot || '').trim();
218
+ if (profilesRoot) {
219
+ const dataRoot = inferDataRootFromProfilesRoot(profilesRoot);
220
+ if (dataRoot) {
221
+ candidates.push(path.join(dataRoot, 'Documents'));
222
+ }
223
+ }
224
+ return [...new Set(candidates.map((item) => path.resolve(item)))];
225
+ }
226
+
227
+ async function collectDefaultExternalSearchBaseRoots() {
228
+ const bases = new Set();
229
+ const home = os.homedir();
230
+ bases.add(home);
231
+ bases.add(path.join(home, 'Documents'));
232
+ bases.add(path.join(home, 'Desktop'));
233
+ bases.add(path.join(home, 'Downloads'));
234
+
235
+ const volumeEntries = await fs.readdir('/Volumes', { withFileTypes: true }).catch(() => []);
236
+ for (const entry of volumeEntries) {
237
+ if (!entry.isDirectory()) {
238
+ continue;
239
+ }
240
+ bases.add(path.join('/Volumes', entry.name));
241
+ }
242
+
243
+ const resolved = [];
244
+ for (const base of bases) {
245
+ if (!base) {
246
+ continue;
247
+ }
248
+ const ok = await isDirectoryPath(base);
249
+ if (ok) {
250
+ resolved.push(path.resolve(base));
251
+ }
252
+ }
253
+ resolved.sort();
254
+ return resolved;
255
+ }
256
+
257
+ function shouldSkipExternalScanDir(name) {
258
+ if (!name) {
259
+ return false;
260
+ }
261
+ if (name.startsWith('.')) {
262
+ return true;
263
+ }
264
+ if (EXTERNAL_STORAGE_SCAN_SKIP_NAMES.has(name)) {
265
+ return true;
266
+ }
267
+ return false;
268
+ }
269
+
270
+ async function findExternalStorageRootsByStructure(baseRoots, options = {}) {
271
+ const maxDepth = Math.max(1, Number(options.searchMaxDepth || EXTERNAL_STORAGE_SCAN_MAX_DEPTH_DEFAULT));
272
+ const visitLimit = Math.max(
273
+ 200,
274
+ Number(options.searchVisitLimit || EXTERNAL_STORAGE_SCAN_MAX_VISITS_DEFAULT)
275
+ );
276
+ const uniqueBaseRoots = [
277
+ ...new Set(
278
+ (baseRoots || [])
279
+ .map((item) => String(item || '').trim())
280
+ .filter(Boolean)
281
+ .map((item) => path.resolve(item))
282
+ ),
283
+ ];
284
+ const found = new Set();
285
+ const truncatedRoots = [];
286
+ let searchedRootsCount = 0;
287
+ let visitedDirs = 0;
288
+
289
+ for (const baseRoot of uniqueBaseRoots) {
290
+ const baseExists = await isDirectoryPath(baseRoot);
291
+ if (!baseExists) {
292
+ continue;
293
+ }
294
+
295
+ searchedRootsCount += 1;
296
+ const queue = [{ dir: baseRoot, depth: 0 }];
297
+ const visited = new Set();
298
+ let visitCount = 0;
299
+
300
+ while (queue.length > 0) {
301
+ if (visitCount >= visitLimit) {
302
+ truncatedRoots.push(baseRoot);
303
+ break;
304
+ }
305
+
306
+ const current = queue.shift();
307
+ if (!current) {
308
+ continue;
309
+ }
310
+ const dir = current.dir;
311
+ if (!dir || visited.has(dir)) {
312
+ continue;
313
+ }
314
+ visited.add(dir);
315
+ visitCount += 1;
316
+ visitedDirs += 1;
317
+
318
+ const markerPath = path.join(dir, EXTERNAL_STORAGE_CACHE_RELATIVE);
319
+ if (await isDirectoryPath(markerPath)) {
320
+ found.add(dir);
321
+ continue;
322
+ }
323
+
324
+ if (current.depth >= maxDepth) {
325
+ continue;
326
+ }
327
+
328
+ const entries = await listDirectoryEntries(dir);
329
+ for (const entry of entries) {
330
+ if (!entry.isDirectory()) {
331
+ continue;
332
+ }
333
+ if (shouldSkipExternalScanDir(entry.name)) {
334
+ continue;
335
+ }
336
+ queue.push({
337
+ dir: path.join(dir, entry.name),
338
+ depth: current.depth + 1,
339
+ });
340
+ }
341
+ }
342
+ }
343
+
344
+ const roots = [...found].sort();
345
+ return {
346
+ roots,
347
+ meta: {
348
+ searchedRootsCount,
349
+ autoDetectedRootCount: roots.length,
350
+ truncatedRoots,
351
+ visitedDirs,
352
+ },
353
+ };
354
+ }
355
+
356
+ function resolveExternalCategoryRoot(externalStorageRoot, categoryRelativePath) {
357
+ if (!String(categoryRelativePath || '').startsWith('Caches/')) {
358
+ return null;
359
+ }
360
+ const suffix = categoryRelativePath.slice('Caches/'.length);
361
+ return path.join(externalStorageRoot, EXTERNAL_STORAGE_CACHE_RELATIVE, suffix);
362
+ }
363
+
364
+ function wildcardSegmentToRegExp(segment) {
365
+ const escaped = segment
366
+ .split('*')
367
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
368
+ .join('.*');
369
+ return new RegExp(`^${escaped}$`);
370
+ }
371
+
372
+ async function resolveRelativePathMatches(basePath, relativePath) {
373
+ const segments = String(relativePath || '')
374
+ .split(/[\\/]+/)
375
+ .filter(Boolean);
376
+ if (segments.length === 0) {
377
+ return [];
378
+ }
379
+
380
+ const results = [];
381
+
382
+ async function walk(currentPath, index) {
383
+ if (index >= segments.length) {
384
+ const stat = await fs.stat(currentPath).catch(() => null);
385
+ if (stat && (stat.isDirectory() || stat.isFile())) {
386
+ results.push({ path: currentPath, stat });
387
+ }
388
+ return;
389
+ }
390
+
391
+ const segment = segments[index];
392
+ const isLast = index === segments.length - 1;
393
+
394
+ if (!segment.includes('*')) {
395
+ const nextPath = path.join(currentPath, segment);
396
+ const stat = await fs.stat(nextPath).catch(() => null);
397
+ if (!stat) {
398
+ return;
399
+ }
400
+ if (!isLast && !stat.isDirectory()) {
401
+ return;
402
+ }
403
+ if (isLast && !stat.isDirectory() && !stat.isFile()) {
404
+ return;
405
+ }
406
+ await walk(nextPath, index + 1);
407
+ return;
408
+ }
409
+
410
+ const matcher = wildcardSegmentToRegExp(segment);
411
+ const entries = await listDirectoryEntries(currentPath);
412
+ for (const entry of entries) {
413
+ if (!matcher.test(entry.name)) {
414
+ continue;
415
+ }
416
+ if (!isLast && !entry.isDirectory()) {
417
+ continue;
418
+ }
419
+ if (isLast && !entry.isDirectory() && !entry.isFile()) {
420
+ continue;
421
+ }
422
+ await walk(path.join(currentPath, entry.name), index + 1);
423
+ }
424
+ }
425
+
426
+ await walk(basePath, 0);
427
+
428
+ const dedup = new Map();
429
+ for (const item of results) {
430
+ dedup.set(item.path, item);
431
+ }
432
+ return [...dedup.values()];
433
+ }
434
+
435
+ async function collectRecursiveDirectoryCandidates(rootPath, maxDepth = 2) {
436
+ const candidates = [];
437
+ const safeDepth = Math.max(1, Number(maxDepth || 1));
438
+
439
+ async function walk(currentPath, depth) {
440
+ const entries = await listDirectoryEntries(currentPath);
441
+ const dirEntries = entries.filter((entry) => entry.isDirectory());
442
+ if (dirEntries.length === 0) {
443
+ if (currentPath !== rootPath) {
444
+ candidates.push({
445
+ name: path.relative(rootPath, currentPath),
446
+ path: currentPath,
447
+ isDirectory: true,
448
+ });
449
+ }
450
+ return;
451
+ }
452
+
453
+ for (const entry of dirEntries) {
454
+ const absPath = path.join(currentPath, entry.name);
455
+ const nextDepth = depth + 1;
456
+ if (nextDepth >= safeDepth) {
457
+ candidates.push({
458
+ name: path.relative(rootPath, absPath),
459
+ path: absPath,
460
+ isDirectory: true,
461
+ });
462
+ continue;
463
+ }
464
+ await walk(absPath, nextDepth);
465
+ }
466
+ }
467
+
468
+ await walk(rootPath, 0);
469
+
470
+ if (candidates.length === 0) {
471
+ const topDirs = await listSubDirectories(rootPath);
472
+ for (const name of topDirs) {
473
+ candidates.push({
474
+ name,
475
+ path: path.join(rootPath, name),
476
+ isDirectory: true,
477
+ });
478
+ }
479
+ }
480
+
481
+ return candidates;
482
+ }
483
+
484
+ async function collectCategoryDirectoryCandidates(categoryKey, rootPath) {
485
+ if (categoryKey === WWSECURITY_KEY) {
486
+ return collectRecursiveDirectoryCandidates(rootPath, 2);
487
+ }
488
+ const children = await listSubDirectories(rootPath);
489
+ return children.map((name) => ({
490
+ name,
491
+ path: path.join(rootPath, name),
492
+ isDirectory: true,
493
+ }));
494
+ }
495
+
496
+ async function collectCategoryDirectFileCandidates(rootPath) {
497
+ const files = await listDirectFiles(rootPath);
498
+ return files.map((name) => ({
499
+ name,
500
+ path: path.join(rootPath, name),
501
+ isDirectory: false,
502
+ }));
503
+ }
504
+
505
+ function parseDuOutput(stdout) {
506
+ const map = new Map();
507
+ const lines = String(stdout || '').split(/\r?\n/);
508
+ for (const line of lines) {
509
+ if (!line) {
510
+ continue;
511
+ }
512
+ const idx = line.indexOf('\t');
513
+ if (idx <= 0) {
514
+ continue;
515
+ }
516
+ const sizeRaw = line.slice(0, idx);
517
+ const p = line.slice(idx + 1);
518
+ const size = Number(sizeRaw);
519
+ if (!Number.isFinite(size)) {
520
+ continue;
521
+ }
522
+ map.set(p, Math.max(0, size));
523
+ }
524
+ return map;
525
+ }
526
+
527
+ async function calculateSizesByNative(candidates, nativeCorePath, onProgress) {
528
+ const chunkSize = 200;
529
+ let done = 0;
530
+
531
+ for (let i = 0; i < candidates.length; i += chunkSize) {
532
+ const chunk = candidates.slice(i, i + chunkSize);
533
+ const args = ['du', ...chunk.map((item) => item.path)];
534
+ const result = spawnSync(nativeCorePath, args, {
535
+ encoding: 'utf-8',
536
+ maxBuffer: 64 * 1024 * 1024,
537
+ });
538
+
539
+ if (result.status !== 0) {
540
+ throw new Error(result.stderr || `native core exited with code ${result.status}`);
541
+ }
542
+
543
+ const sizeMap = parseDuOutput(result.stdout);
544
+ for (const item of chunk) {
545
+ item.sizeBytes = sizeMap.get(item.path) ?? 0;
546
+ done += 1;
547
+ if (typeof onProgress === 'function') {
548
+ onProgress(done, candidates.length);
549
+ }
550
+ }
551
+ }
552
+ }
553
+
554
+ async function calculateSizesByNode(candidates, sizeConcurrency, onProgress) {
555
+ let progress = 0;
556
+ await mapLimit(candidates, sizeConcurrency, async (item) => {
557
+ const sizeBytes = await calculateDirectorySize(item.path);
558
+ item.sizeBytes = sizeBytes;
559
+ progress += 1;
560
+ if (typeof onProgress === 'function') {
561
+ onProgress(progress, candidates.length);
562
+ }
563
+ });
564
+ }
565
+
566
+ async function calculateSizesWithEngine({ candidates, nativeCorePath, sizeConcurrency = 4, onProgress }) {
567
+ let engineUsed = nativeCorePath ? 'zig' : 'node';
568
+ let nativeFallbackReason = null;
569
+ let nativeFailed = false;
570
+
571
+ if (nativeCorePath) {
572
+ try {
573
+ await calculateSizesByNative(candidates, nativeCorePath, onProgress);
574
+ } catch (error) {
575
+ nativeFailed = true;
576
+ engineUsed = 'node';
577
+ nativeFallbackReason =
578
+ error instanceof Error && error.message
579
+ ? `zig核心扫描失败: ${error.message}`
580
+ : 'zig核心扫描失败,已回退Node引擎';
581
+ }
582
+ }
583
+
584
+ if (!nativeCorePath || nativeFailed) {
585
+ engineUsed = 'node';
586
+ await calculateSizesByNode(candidates, sizeConcurrency, onProgress);
587
+ }
588
+
589
+ return { engineUsed, nativeFallbackReason };
590
+ }
591
+
592
+ export async function detectExternalStorageRoots(options = {}) {
593
+ const configuredRoots = Array.isArray(options.configuredRoots) ? options.configuredRoots : [];
594
+ const builtInCandidates = collectBuiltInStorageRootCandidates(options);
595
+ const autoDetect =
596
+ typeof options.autoDetect === 'boolean'
597
+ ? options.autoDetect
598
+ : parseBooleanEnv(process.env.WECOM_CLEANER_EXTERNAL_AUTO_DETECT, true);
599
+ const returnMeta = options.returnMeta === true;
600
+ const cacheKey = buildExternalDetectCacheKey(options, autoDetect);
601
+ const cacheTtlMs = Math.max(0, Number(options.cacheTtlMs || EXTERNAL_STORAGE_CACHE_TTL_MS_DEFAULT));
602
+ if (cacheTtlMs > 0) {
603
+ const cached = externalStorageDetectCache.get(cacheKey);
604
+ if (cached && cached.expiresAt > Date.now()) {
605
+ if (returnMeta) {
606
+ return {
607
+ roots: [...cached.roots],
608
+ meta: {
609
+ ...(cached.meta || {}),
610
+ fromCache: true,
611
+ },
612
+ };
613
+ }
614
+ return [...cached.roots];
615
+ }
616
+ }
617
+
618
+ const resolved = [];
619
+ const seen = new Set();
620
+ const sourceByRoot = new Map();
621
+ let autoDetectMeta = {
622
+ searchedRootsCount: 0,
623
+ autoDetectedRootCount: 0,
624
+ truncatedRoots: [],
625
+ visitedDirs: 0,
626
+ };
627
+
628
+ for (const candidate of builtInCandidates) {
629
+ const root = await resolveExternalStorageRoot(candidate);
630
+ if (!root || seen.has(root)) {
631
+ continue;
632
+ }
633
+ seen.add(root);
634
+ resolved.push(root);
635
+ sourceByRoot.set(root, EXTERNAL_SOURCE_BUILTIN);
636
+ }
637
+
638
+ for (const candidate of configuredRoots) {
639
+ const root = await resolveExternalStorageRoot(candidate);
640
+ if (!root || seen.has(root)) {
641
+ continue;
642
+ }
643
+ seen.add(root);
644
+ resolved.push(root);
645
+ sourceByRoot.set(root, EXTERNAL_SOURCE_CONFIGURED);
646
+ }
647
+
648
+ if (autoDetect) {
649
+ const baseRoots =
650
+ Array.isArray(options.searchBaseRoots) && options.searchBaseRoots.length > 0
651
+ ? options.searchBaseRoots
652
+ : await collectDefaultExternalSearchBaseRoots();
653
+ const autoScan = await findExternalStorageRootsByStructure(baseRoots, options);
654
+ autoDetectMeta = autoScan.meta;
655
+ for (const root of autoScan.roots) {
656
+ const normalized = await resolveExternalStorageRoot(root);
657
+ if (!normalized || seen.has(normalized)) {
658
+ continue;
659
+ }
660
+ seen.add(normalized);
661
+ resolved.push(normalized);
662
+ sourceByRoot.set(normalized, EXTERNAL_SOURCE_AUTO);
663
+ }
664
+ }
665
+
666
+ resolved.sort();
667
+ const rootSources = Object.fromEntries(
668
+ resolved.map((item) => [item, sourceByRoot.get(item) || EXTERNAL_SOURCE_AUTO])
669
+ );
670
+ const sourceCounts = {
671
+ [EXTERNAL_SOURCE_BUILTIN]: 0,
672
+ [EXTERNAL_SOURCE_CONFIGURED]: 0,
673
+ [EXTERNAL_SOURCE_AUTO]: 0,
674
+ };
675
+ for (const source of Object.values(rootSources)) {
676
+ if (source === EXTERNAL_SOURCE_BUILTIN) {
677
+ sourceCounts[EXTERNAL_SOURCE_BUILTIN] += 1;
678
+ continue;
679
+ }
680
+ if (source === EXTERNAL_SOURCE_CONFIGURED) {
681
+ sourceCounts[EXTERNAL_SOURCE_CONFIGURED] += 1;
682
+ continue;
683
+ }
684
+ sourceCounts[EXTERNAL_SOURCE_AUTO] += 1;
685
+ }
686
+
687
+ const meta = {
688
+ searchedRootsCount: autoDetectMeta.searchedRootsCount,
689
+ autoDetectedRootCount: autoDetectMeta.autoDetectedRootCount,
690
+ truncatedRoots: autoDetectMeta.truncatedRoots,
691
+ visitedDirs: autoDetectMeta.visitedDirs,
692
+ resolvedRootCount: resolved.length,
693
+ rootSources,
694
+ sourceCounts,
695
+ fromCache: false,
696
+ };
697
+
698
+ if (cacheTtlMs > 0) {
699
+ externalStorageDetectCache.set(cacheKey, {
700
+ roots: [...resolved],
701
+ meta,
702
+ expiresAt: Date.now() + cacheTtlMs,
703
+ });
704
+ if (externalStorageDetectCache.size > 32) {
705
+ externalStorageDetectCache.clear();
706
+ }
707
+ }
708
+
709
+ if (returnMeta) {
710
+ return {
711
+ roots: [...resolved],
712
+ meta,
713
+ };
714
+ }
715
+ return resolved;
716
+ }
717
+
718
+ export async function discoverAccounts(rootDir, aliases = {}) {
719
+ const currentProfileId = await readCurrentProfileId(rootDir);
720
+
721
+ let entries;
722
+ try {
723
+ entries = await fs.readdir(rootDir, { withFileTypes: true });
724
+ } catch {
725
+ return [];
726
+ }
727
+
728
+ const dirs = entries
729
+ .filter((entry) => entry.isDirectory())
730
+ .map((entry) => entry.name)
731
+ .sort((a, b) => a.localeCompare(b));
732
+
733
+ const accounts = [];
734
+
735
+ for (const id of dirs) {
736
+ const profilePath = path.join(rootDir, id);
737
+ const cachesPath = path.join(profilePath, 'Caches');
738
+ const ioPath = path.join(profilePath, 'io_data.json');
739
+
740
+ const hasCaches = await fs
741
+ .stat(cachesPath)
742
+ .then((s) => s.isDirectory())
743
+ .catch(() => false);
744
+ const hasIo = await fs
745
+ .stat(ioPath)
746
+ .then((s) => s.isFile())
747
+ .catch(() => false);
748
+
749
+ if (!hasCaches && !hasIo) {
750
+ continue;
751
+ }
752
+
753
+ const identity = await extractIdentity(profilePath);
754
+ const alias = aliases[id] || {};
755
+
756
+ const userName = (alias.userName || identity.userName || '未知用户').trim();
757
+ const corpName = (alias.corpName || identity.corpName || '未知企业').trim();
758
+
759
+ const stat = await fs.stat(profilePath).catch(() => null);
760
+
761
+ accounts.push({
762
+ id,
763
+ shortId: shortId(id),
764
+ profilePath,
765
+ userName,
766
+ corpName,
767
+ isCurrent: id === currentProfileId,
768
+ mtimeMs: stat?.mtimeMs || 0,
769
+ });
770
+ }
771
+
772
+ accounts.sort((a, b) => {
773
+ if (a.isCurrent && !b.isCurrent) {
774
+ return -1;
775
+ }
776
+ if (!a.isCurrent && b.isCurrent) {
777
+ return 1;
778
+ }
779
+ return b.mtimeMs - a.mtimeMs;
780
+ });
781
+
782
+ return accounts;
783
+ }
784
+
785
+ export async function collectAvailableMonths(
786
+ accounts,
787
+ selectedAccountIds,
788
+ categoryKeys,
789
+ externalStorageRoots = []
790
+ ) {
791
+ const selectedSet = new Set(selectedAccountIds || []);
792
+ const months = [];
793
+
794
+ for (const account of accounts) {
795
+ if (selectedSet.size > 0 && !selectedSet.has(account.id)) {
796
+ continue;
797
+ }
798
+ for (const key of categoryKeys) {
799
+ const category = CATEGORY_BY_KEY.get(key);
800
+ if (!category) {
801
+ continue;
802
+ }
803
+ const categoryPath = path.join(account.profilePath, category.relativePath);
804
+ const children = await collectCategoryDirectoryCandidates(key, categoryPath);
805
+ for (const child of children) {
806
+ const monthKey = normalizeMonthKey(path.basename(child.name));
807
+ if (monthKey) {
808
+ months.push(monthKey);
809
+ }
810
+ }
811
+ }
812
+ }
813
+
814
+ for (const externalRoot of externalStorageRoots || []) {
815
+ for (const key of categoryKeys) {
816
+ const category = CATEGORY_BY_KEY.get(key);
817
+ if (!category) {
818
+ continue;
819
+ }
820
+ const categoryPath = resolveExternalCategoryRoot(externalRoot, category.relativePath);
821
+ if (!categoryPath) {
822
+ continue;
823
+ }
824
+ const children = await collectCategoryDirectoryCandidates(key, categoryPath);
825
+ for (const child of children) {
826
+ const monthKey = normalizeMonthKey(path.basename(child.name));
827
+ if (monthKey) {
828
+ months.push(monthKey);
829
+ }
830
+ }
831
+ }
832
+ }
833
+
834
+ return sortMonthKeys(months, 'asc');
835
+ }
836
+
837
+ export async function collectCleanupTargets({
838
+ accounts,
839
+ selectedAccountIds,
840
+ categoryKeys,
841
+ monthFilters,
842
+ includeNonMonthDirs,
843
+ externalStorageRoots = [],
844
+ nativeCorePath,
845
+ sizeConcurrency = 4,
846
+ onProgress,
847
+ }) {
848
+ const selectedSet = new Set(selectedAccountIds || []);
849
+ const normalizedMonths = (monthFilters || []).map((x) => normalizeMonthKey(x)).filter(Boolean);
850
+ const monthSet = normalizedMonths.length > 0 ? new Set(normalizedMonths) : null;
851
+
852
+ const candidates = [];
853
+
854
+ for (const account of accounts) {
855
+ if (selectedSet.size > 0 && !selectedSet.has(account.id)) {
856
+ continue;
857
+ }
858
+ for (const key of categoryKeys) {
859
+ const category = CATEGORY_BY_KEY.get(key);
860
+ if (!category) {
861
+ continue;
862
+ }
863
+
864
+ const rootPath = path.join(account.profilePath, category.relativePath);
865
+ const dirCandidates = await collectCategoryDirectoryCandidates(key, rootPath);
866
+
867
+ for (const child of dirCandidates) {
868
+ const monthKey = normalizeMonthKey(path.basename(child.name));
869
+ const isMonthDir = Boolean(monthKey);
870
+
871
+ const includeMonth = isMonthDir && (!monthSet || monthSet.has(monthKey));
872
+ const includeNonMonth = !isMonthDir && Boolean(includeNonMonthDirs);
873
+
874
+ if (!includeMonth && !includeNonMonth) {
875
+ continue;
876
+ }
877
+
878
+ candidates.push({
879
+ accountId: account.id,
880
+ accountShortId: account.shortId,
881
+ userName: account.userName,
882
+ corpName: account.corpName,
883
+ accountPath: account.profilePath,
884
+ categoryKey: key,
885
+ categoryLabel: category.label,
886
+ categoryPath: category.relativePath,
887
+ monthKey: monthKey || null,
888
+ isMonthDir,
889
+ directoryName: child.name,
890
+ path: child.path,
891
+ isDirectory: child.isDirectory,
892
+ sizeBytes: 0,
893
+ });
894
+ }
895
+
896
+ if (includeNonMonthDirs) {
897
+ const fileCandidates = await collectCategoryDirectFileCandidates(rootPath);
898
+ for (const file of fileCandidates) {
899
+ candidates.push({
900
+ accountId: account.id,
901
+ accountShortId: account.shortId,
902
+ userName: account.userName,
903
+ corpName: account.corpName,
904
+ accountPath: account.profilePath,
905
+ categoryKey: key,
906
+ categoryLabel: category.label,
907
+ categoryPath: category.relativePath,
908
+ monthKey: null,
909
+ isMonthDir: false,
910
+ directoryName: file.name,
911
+ path: file.path,
912
+ isDirectory: false,
913
+ sizeBytes: 0,
914
+ });
915
+ }
916
+ }
917
+ }
918
+ }
919
+
920
+ for (const externalRoot of externalStorageRoots || []) {
921
+ const externalLabel = `外部存储(${path.basename(externalRoot) || 'WXWork_Data'})`;
922
+ const externalId = `external:${externalRoot}`;
923
+
924
+ for (const key of categoryKeys) {
925
+ const category = CATEGORY_BY_KEY.get(key);
926
+ if (!category) {
927
+ continue;
928
+ }
929
+
930
+ const rootPath = resolveExternalCategoryRoot(externalRoot, category.relativePath);
931
+ if (!rootPath) {
932
+ continue;
933
+ }
934
+
935
+ const dirCandidates = await collectCategoryDirectoryCandidates(key, rootPath);
936
+ for (const child of dirCandidates) {
937
+ const monthKey = normalizeMonthKey(path.basename(child.name));
938
+ const isMonthDir = Boolean(monthKey);
939
+
940
+ const includeMonth = isMonthDir && (!monthSet || monthSet.has(monthKey));
941
+ const includeNonMonth = !isMonthDir && Boolean(includeNonMonthDirs);
942
+
943
+ if (!includeMonth && !includeNonMonth) {
944
+ continue;
945
+ }
946
+
947
+ candidates.push({
948
+ accountId: externalId,
949
+ accountShortId: '外部存储',
950
+ userName: externalLabel,
951
+ corpName: externalRoot,
952
+ accountPath: externalRoot,
953
+ categoryKey: key,
954
+ categoryLabel: `${category.label}(外部)`,
955
+ categoryPath: path.relative(externalRoot, rootPath) || rootPath,
956
+ monthKey: monthKey || null,
957
+ isMonthDir,
958
+ directoryName: child.name,
959
+ path: child.path,
960
+ isDirectory: child.isDirectory,
961
+ sizeBytes: 0,
962
+ externalStorageRoot: externalRoot,
963
+ isExternalStorage: true,
964
+ });
965
+ }
966
+
967
+ if (includeNonMonthDirs) {
968
+ const fileCandidates = await collectCategoryDirectFileCandidates(rootPath);
969
+ for (const file of fileCandidates) {
970
+ candidates.push({
971
+ accountId: externalId,
972
+ accountShortId: '外部存储',
973
+ userName: externalLabel,
974
+ corpName: externalRoot,
975
+ accountPath: externalRoot,
976
+ categoryKey: key,
977
+ categoryLabel: `${category.label}(外部)`,
978
+ categoryPath: path.relative(externalRoot, rootPath) || rootPath,
979
+ monthKey: null,
980
+ isMonthDir: false,
981
+ directoryName: file.name,
982
+ path: file.path,
983
+ isDirectory: false,
984
+ sizeBytes: 0,
985
+ externalStorageRoot: externalRoot,
986
+ isExternalStorage: true,
987
+ });
988
+ }
989
+ }
990
+ }
991
+ }
992
+
993
+ const { engineUsed, nativeFallbackReason } = await calculateSizesWithEngine({
994
+ candidates,
995
+ nativeCorePath,
996
+ sizeConcurrency,
997
+ onProgress,
998
+ });
999
+
1000
+ candidates.sort((a, b) => b.sizeBytes - a.sizeBytes);
1001
+ return {
1002
+ targets: candidates,
1003
+ engineUsed,
1004
+ nativeFallbackReason,
1005
+ };
1006
+ }
1007
+
1008
+ export async function scanSpaceGovernanceTargets({
1009
+ accounts,
1010
+ selectedAccountIds,
1011
+ rootDir,
1012
+ externalStorageRoots = [],
1013
+ nativeCorePath,
1014
+ autoSuggest = {},
1015
+ sizeConcurrency = 4,
1016
+ onProgress,
1017
+ }) {
1018
+ const dataRoot = inferDataRootFromProfilesRoot(rootDir);
1019
+ const selectedSet = new Set(selectedAccountIds || []);
1020
+ const activeAccounts =
1021
+ selectedSet.size > 0 ? accounts.filter((account) => selectedSet.has(account.id)) : [...accounts];
1022
+
1023
+ const candidates = [];
1024
+
1025
+ for (const target of SPACE_GOVERNANCE_TARGETS) {
1026
+ if (target.scope === 'profile') {
1027
+ for (const account of activeAccounts) {
1028
+ const matches = await resolveRelativePathMatches(account.profilePath, target.relativePath);
1029
+ for (const matched of matches) {
1030
+ const absPath = matched.path;
1031
+ const relPath = path.relative(account.profilePath, absPath) || target.relativePath;
1032
+ const idSuffix = relPath.replace(/[\\/]/g, '|');
1033
+
1034
+ candidates.push({
1035
+ id: `${target.key}:${account.id}:${idSuffix}`,
1036
+ scope: 'space_governance',
1037
+ path: absPath,
1038
+ directoryName: path.basename(absPath),
1039
+ sizeBytes: 0,
1040
+ mtimeMs: matched.stat?.mtimeMs || 0,
1041
+ targetKey: target.key,
1042
+ targetLabel: target.label,
1043
+ targetDesc: target.desc,
1044
+ tier: target.tier,
1045
+ deletable: target.deletable !== false && target.tier !== SPACE_GOVERNANCE_TIERS.PROTECTED,
1046
+ accountId: account.id,
1047
+ accountShortId: account.shortId,
1048
+ userName: account.userName,
1049
+ corpName: account.corpName,
1050
+ accountPath: account.profilePath,
1051
+ categoryKey: target.key,
1052
+ categoryLabel: target.label,
1053
+ monthKey: null,
1054
+ categoryPath: relPath,
1055
+ });
1056
+ }
1057
+ }
1058
+ continue;
1059
+ }
1060
+
1061
+ if (!dataRoot) {
1062
+ continue;
1063
+ }
1064
+ const matches = await resolveRelativePathMatches(dataRoot, target.relativePath);
1065
+ for (const matched of matches) {
1066
+ const absPath = matched.path;
1067
+ const relPath = path.relative(dataRoot, absPath) || target.relativePath;
1068
+ const idSuffix = relPath.replace(/[\\/]/g, '|');
1069
+
1070
+ candidates.push({
1071
+ id: `${target.key}:global:${idSuffix}`,
1072
+ scope: 'space_governance',
1073
+ path: absPath,
1074
+ directoryName: path.basename(absPath),
1075
+ sizeBytes: 0,
1076
+ mtimeMs: matched.stat?.mtimeMs || 0,
1077
+ targetKey: target.key,
1078
+ targetLabel: target.label,
1079
+ targetDesc: target.desc,
1080
+ tier: target.tier,
1081
+ deletable: target.deletable !== false && target.tier !== SPACE_GOVERNANCE_TIERS.PROTECTED,
1082
+ accountId: null,
1083
+ accountShortId: '-',
1084
+ userName: '全局',
1085
+ corpName: '-',
1086
+ accountPath: dataRoot,
1087
+ categoryKey: target.key,
1088
+ categoryLabel: target.label,
1089
+ monthKey: null,
1090
+ categoryPath: relPath,
1091
+ });
1092
+ }
1093
+ }
1094
+
1095
+ for (const externalRoot of externalStorageRoots || []) {
1096
+ const cacheRoot = path.join(externalRoot, EXTERNAL_STORAGE_CACHE_RELATIVE);
1097
+ const stat = await fs.stat(cacheRoot).catch(() => null);
1098
+ if (!stat || !stat.isDirectory()) {
1099
+ continue;
1100
+ }
1101
+
1102
+ const relPath = path.relative(externalRoot, cacheRoot) || cacheRoot;
1103
+ const idSuffix = `${externalRoot}:${relPath}`.replace(/[\\/]/g, '|');
1104
+ const labelSuffix = path.basename(externalRoot) || 'WXWork_Data';
1105
+
1106
+ candidates.push({
1107
+ id: `external_wxwork_files_caches:global:${idSuffix}`,
1108
+ scope: 'space_governance',
1109
+ path: cacheRoot,
1110
+ directoryName: path.basename(cacheRoot),
1111
+ sizeBytes: 0,
1112
+ mtimeMs: stat.mtimeMs || 0,
1113
+ targetKey: 'external_wxwork_files_caches',
1114
+ targetLabel: `外部文件缓存目录(${labelSuffix})`,
1115
+ targetDesc: '企业微信外部文件存储缓存目录,清理后可按需重新下载。',
1116
+ tier: SPACE_GOVERNANCE_TIERS.CAUTION,
1117
+ deletable: true,
1118
+ accountId: null,
1119
+ accountShortId: '外部存储',
1120
+ userName: '外部存储',
1121
+ corpName: externalRoot,
1122
+ accountPath: externalRoot,
1123
+ categoryKey: 'external_wxwork_files_caches',
1124
+ categoryLabel: `外部文件缓存目录(${labelSuffix})`,
1125
+ monthKey: null,
1126
+ categoryPath: relPath,
1127
+ externalStorageRoot: externalRoot,
1128
+ isExternalStorage: true,
1129
+ });
1130
+ }
1131
+
1132
+ const sizeResult = await calculateSizesWithEngine({
1133
+ candidates,
1134
+ nativeCorePath,
1135
+ sizeConcurrency,
1136
+ onProgress,
1137
+ });
1138
+
1139
+ const suggestSizeThresholdMB = Number(autoSuggest.sizeThresholdMB || 512);
1140
+ const suggestIdleDays = Number(autoSuggest.idleDays || 7);
1141
+ const suggestSizeThresholdBytes = Math.max(1, suggestSizeThresholdMB) * 1024 * 1024;
1142
+ const suggestIdleMs = Math.max(1, suggestIdleDays) * 24 * 3600 * 1000;
1143
+ const now = Date.now();
1144
+
1145
+ for (const target of candidates) {
1146
+ const idleMs = Math.max(0, now - Number(target.mtimeMs || 0));
1147
+ const idleDays = idleMs / (24 * 3600 * 1000);
1148
+ const recentlyActive = idleMs < suggestIdleMs;
1149
+ const suggested =
1150
+ target.deletable &&
1151
+ target.sizeBytes >= suggestSizeThresholdBytes &&
1152
+ !recentlyActive &&
1153
+ target.tier !== SPACE_GOVERNANCE_TIERS.PROTECTED;
1154
+
1155
+ target.idleDays = idleDays;
1156
+ target.recentlyActive = recentlyActive;
1157
+ target.suggested = suggested;
1158
+ }
1159
+
1160
+ candidates.sort((a, b) => b.sizeBytes - a.sizeBytes);
1161
+
1162
+ let totalBytes = 0;
1163
+ const byTierMap = new Map();
1164
+ for (const target of candidates) {
1165
+ totalBytes += target.sizeBytes;
1166
+ const tier = target.tier;
1167
+ if (!byTierMap.has(tier)) {
1168
+ byTierMap.set(tier, {
1169
+ tier,
1170
+ count: 0,
1171
+ sizeBytes: 0,
1172
+ suggestedCount: 0,
1173
+ });
1174
+ }
1175
+ const row = byTierMap.get(tier);
1176
+ row.count += 1;
1177
+ row.sizeBytes += target.sizeBytes;
1178
+ row.suggestedCount += target.suggested ? 1 : 0;
1179
+ }
1180
+
1181
+ return {
1182
+ targets: candidates,
1183
+ totalBytes,
1184
+ byTier: [...byTierMap.values()],
1185
+ dataRoot,
1186
+ suggestSizeThresholdMB,
1187
+ suggestIdleDays,
1188
+ engineUsed: sizeResult.engineUsed,
1189
+ nativeFallbackReason: sizeResult.nativeFallbackReason,
1190
+ };
1191
+ }
1192
+
1193
+ export async function analyzeCacheFootprint({
1194
+ accounts,
1195
+ selectedAccountIds,
1196
+ categoryKeys,
1197
+ externalStorageRoots = [],
1198
+ nativeCorePath,
1199
+ onProgress,
1200
+ }) {
1201
+ const scan = await collectCleanupTargets({
1202
+ accounts,
1203
+ selectedAccountIds,
1204
+ categoryKeys,
1205
+ monthFilters: [],
1206
+ includeNonMonthDirs: true,
1207
+ externalStorageRoots,
1208
+ nativeCorePath,
1209
+ onProgress,
1210
+ });
1211
+ const targets = scan.targets;
1212
+
1213
+ let totalBytes = 0;
1214
+ const accountMap = new Map();
1215
+ const categoryMap = new Map();
1216
+ const monthMap = new Map();
1217
+
1218
+ for (const target of targets) {
1219
+ totalBytes += target.sizeBytes;
1220
+
1221
+ const accountKey = target.accountId;
1222
+ const categoryKey = target.categoryKey;
1223
+ const monthKey = target.monthKey || '非月份目录';
1224
+
1225
+ if (!accountMap.has(accountKey)) {
1226
+ accountMap.set(accountKey, {
1227
+ accountId: target.accountId,
1228
+ userName: target.userName,
1229
+ corpName: target.corpName,
1230
+ shortId: target.accountShortId,
1231
+ sizeBytes: 0,
1232
+ count: 0,
1233
+ });
1234
+ }
1235
+ if (!categoryMap.has(categoryKey)) {
1236
+ categoryMap.set(categoryKey, {
1237
+ categoryKey,
1238
+ categoryLabel: CATEGORY_MAP.get(categoryKey)?.label || categoryKey,
1239
+ sizeBytes: 0,
1240
+ count: 0,
1241
+ });
1242
+ }
1243
+ if (!monthMap.has(monthKey)) {
1244
+ monthMap.set(monthKey, {
1245
+ monthKey,
1246
+ sizeBytes: 0,
1247
+ count: 0,
1248
+ });
1249
+ }
1250
+
1251
+ const accountRow = accountMap.get(accountKey);
1252
+ accountRow.sizeBytes += target.sizeBytes;
1253
+ accountRow.count += 1;
1254
+
1255
+ const categoryRow = categoryMap.get(categoryKey);
1256
+ categoryRow.sizeBytes += target.sizeBytes;
1257
+ categoryRow.count += 1;
1258
+
1259
+ const monthRow = monthMap.get(monthKey);
1260
+ monthRow.sizeBytes += target.sizeBytes;
1261
+ monthRow.count += 1;
1262
+ }
1263
+
1264
+ const accountsSummary = [...accountMap.values()].sort((a, b) => b.sizeBytes - a.sizeBytes);
1265
+ const categoriesSummary = [...categoryMap.values()].sort((a, b) => b.sizeBytes - a.sizeBytes);
1266
+ const monthsSummary = [...monthMap.values()].sort((a, b) => b.sizeBytes - a.sizeBytes);
1267
+
1268
+ return {
1269
+ targets,
1270
+ totalBytes,
1271
+ accountsSummary,
1272
+ categoriesSummary,
1273
+ monthsSummary,
1274
+ engineUsed: scan.engineUsed,
1275
+ nativeFallbackReason: scan.nativeFallbackReason,
1276
+ };
1277
+ }