Skip to content

Commit 7827921

Browse files
committed
Add cleanup audit manifests
1 parent 3f4a429 commit 7827921

5 files changed

Lines changed: 258 additions & 32 deletions

File tree

main.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def excepthook(exc_type, exc_value, exc_traceback):
6464
import datetime, time, re, math, shutil, json, logging
6565

6666
from utils.deleteThread import *
67+
from utils.cleanupManifest import combine_cleanup_results, write_cleanup_manifest
6768
from utils.multiDeleteThread import multiDeleteThread
6869
from utils.selectVersion import *
6970
from utils.selectVersion import check_dir, existing_user_config, find_all_wechat_paths, get_dir_name, \
@@ -104,6 +105,7 @@ def ensure_writable_dir(path):
104105
STATE_PATH = os.path.join(working_dir, "clean_state.json")
105106
WHITELIST_PATH = os.path.join(working_dir, "whitelist.txt")
106107
PREVIEW_PATH = os.path.join(working_dir, "last_scan_preview.txt")
108+
CLEANUP_MANIFEST_DIR = os.path.join(working_dir, "cleanup_manifests")
107109
APP_ICON_PATH = os.path.join(resource_dir, "images", "wechat.png")
108110

109111
logging.basicConfig(
@@ -1052,8 +1054,11 @@ def execute_delete(self):
10521054

10531055
share_thread_arr = [0]
10541056
direct_delete = load_config_file().get("global", {}).get("direct_delete", False)
1057+
self.cleanup_results = []
1058+
self.cleanup_expected_threads = 1
10551059
thread = multiDeleteThread(selected_files, selected_dirs, share_thread_arr, direct_delete=direct_delete)
10561060
thread.delete_process_signal.connect(self.callback)
1061+
thread.delete_complete_signal.connect(self.on_delete_complete)
10571062
self.thread_list.append(thread)
10581063
thread.start()
10591064

@@ -1771,6 +1776,26 @@ def callback(self, v):
17711776
self.auto_clean_running = False
17721777
return
17731778

1779+
def on_delete_complete(self, result):
1780+
if not hasattr(self, "cleanup_results"):
1781+
self.cleanup_results = []
1782+
self.cleanup_results.append(result)
1783+
expected = max(1, int(getattr(self, "cleanup_expected_threads", 1)))
1784+
if len(self.cleanup_results) < expected:
1785+
return
1786+
1787+
combined = combine_cleanup_results(self.cleanup_results)
1788+
manifest_path = write_cleanup_manifest(combined, CLEANUP_MANIFEST_DIR)
1789+
self.last_cleanup_manifest = manifest_path
1790+
logging.info("清理审计记录已保存:%s", manifest_path)
1791+
1792+
failed_count = combined.get("failed_count", 0)
1793+
skipped_count = combined.get("skipped_count", 0)
1794+
if failed_count:
1795+
self.setWarninginfo(f"清理完成,但有 {failed_count} 个失败。审计记录:{manifest_path}")
1796+
else:
1797+
self.setSuccessinfo(f"清理完成,审计记录已保存:{manifest_path}。跳过 {skipped_count} 个受保护或不存在的路径。")
1798+
17741799
def should_run_auto_clean(self, config):
17751800
global_config = config.get("global", {})
17761801
if not global_config.get("auto_clean_enable", False):
@@ -1804,6 +1829,8 @@ def justdoit(self, auto_mode=False):
18041829
detail_lines = []
18051830
share_thread_arr = [0]
18061831
system_cache_added = False
1832+
self.cleanup_results = []
1833+
self.cleanup_expected_threads = 0
18071834

18081835
for i, value in enumerate(self.config.get("users", [])):
18091836
file_list = []
@@ -1832,6 +1859,7 @@ def justdoit(self, auto_mode=False):
18321859
direct_delete = self.config.get("global", {}).get("direct_delete", False)
18331860
thread = multiDeleteThread(file_list, dir_list, share_thread_arr, direct_delete=direct_delete)
18341861
thread.delete_process_signal.connect(self.callback)
1862+
thread.delete_complete_signal.connect(self.on_delete_complete)
18351863
self.thread_list.append(thread)
18361864

18371865
if not need_clean:
@@ -1842,6 +1870,7 @@ def justdoit(self, auto_mode=False):
18421870
self.total_file = total_file
18431871
self.total_dir = total_dir
18441872
self.total_size = total_stats.get("total_size", 0)
1873+
self.cleanup_expected_threads = len(self.thread_list)
18451874
self.bar_progress.setRange(0, 100)
18461875
self.bar_progress.setValue(0)
18471876
if not auto_mode or self.config.get("global", {}).get("auto_clean_confirm", True):

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ https://wwbie.lanzoue.com/iQlBl3v2rk6f)
3333
3. 自由设置需要删除的文件的距离时间,默认 365 天;
3434
4. 删除后的文件放置在回收站中,检查后自行清空,防止删错需要的文件;
3535
5. 支持定期自动清理;
36+
6. 清理完成后生成本地 JSON 审计记录,便于复查已处理、跳过和失败的路径;
3637

3738
## 运行截图
3839

tests/test_cleanup_manifest.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import json
2+
import tempfile
3+
import unittest
4+
from pathlib import Path
5+
6+
from utils.cleanupManifest import cleanup_result_summary, delete_path_for_manifest, write_cleanup_manifest
7+
8+
9+
def write_file(path, content=b"x"):
10+
path.parent.mkdir(parents=True, exist_ok=True)
11+
path.write_bytes(content)
12+
return path
13+
14+
15+
class CleanupManifestTest(unittest.TestCase):
16+
def setUp(self):
17+
self.tmp = tempfile.TemporaryDirectory()
18+
self.root = Path(self.tmp.name)
19+
20+
def tearDown(self):
21+
self.tmp.cleanup()
22+
23+
def test_delete_records_success_skips_and_failures(self):
24+
ok_file = write_file(self.root / "old-video.mp4", b"video")
25+
protected_file = write_file(self.root / "message.db", b"sqlite")
26+
busy_file = write_file(self.root / "busy.tmp", b"busy")
27+
trashed = []
28+
29+
def fake_trash(path):
30+
if Path(path).name == "busy.tmp":
31+
raise RuntimeError("file is busy")
32+
trashed.append(path)
33+
34+
records = [
35+
delete_path_for_manifest(str(ok_file), "file", direct_delete=False, trash_func=fake_trash),
36+
delete_path_for_manifest(str(protected_file), "file", direct_delete=False, trash_func=fake_trash),
37+
delete_path_for_manifest(str(busy_file), "file", direct_delete=False, trash_func=fake_trash),
38+
]
39+
result = cleanup_result_summary(records, direct_delete=False)
40+
41+
by_name = {Path(row["path"]).name: row for row in result["records"]}
42+
self.assertEqual(by_name["old-video.mp4"]["status"], "trashed")
43+
self.assertEqual(by_name["message.db"]["status"], "skipped")
44+
self.assertEqual(by_name["message.db"]["reason"], "protected_extension")
45+
self.assertEqual(by_name["busy.tmp"]["status"], "failed")
46+
self.assertEqual(by_name["busy.tmp"]["error"], "file is busy")
47+
self.assertEqual(result["processed_count"], 1)
48+
self.assertEqual(result["skipped_count"], 1)
49+
self.assertEqual(result["failed_count"], 1)
50+
self.assertEqual([Path(path).name for path in trashed], ["old-video.mp4"])
51+
52+
def test_write_cleanup_manifest_persists_json_summary(self):
53+
records = [
54+
{
55+
"path": "/tmp/old-video.mp4",
56+
"type": "file",
57+
"action": "trash",
58+
"status": "trashed",
59+
"size_bytes": 5,
60+
"size": "5 B",
61+
"error": "",
62+
}
63+
]
64+
result = cleanup_result_summary(records, direct_delete=False)
65+
66+
manifest_path = write_cleanup_manifest(result, self.root / "cleanup_manifests")
67+
payload = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
68+
69+
self.assertEqual(payload["schema_version"], 1)
70+
self.assertEqual(payload["action"], "trash")
71+
self.assertEqual(payload["processed_count"], 1)
72+
self.assertEqual(payload["records"][0]["path"], "/tmp/old-video.mp4")
73+
74+
75+
if __name__ == "__main__":
76+
unittest.main()

utils/cleanupManifest.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import json
2+
import os
3+
import shutil
4+
from datetime import datetime
5+
from pathlib import Path
6+
7+
8+
PROTECTED_EXTS = {
9+
".db", ".sqlite", ".sqlite3", ".db-shm", ".db-wal", ".ldb", ".sst",
10+
".dll", ".exe", ".msi", ".sys", ".ocx", ".pyd", ".so", ".dylib",
11+
".bat", ".cmd", ".ps1", ".vbs", ".js", ".jar", ".pak",
12+
}
13+
14+
15+
def is_protected_file(file_path):
16+
return os.path.splitext(str(file_path))[1].lower() in PROTECTED_EXTS
17+
18+
19+
def human_size(num_bytes):
20+
value = float(num_bytes or 0)
21+
for unit in ["B", "KB", "MB", "GB", "TB"]:
22+
if value < 1024 or unit == "TB":
23+
if unit == "B":
24+
return f"{int(value)} {unit}"
25+
return f"{value:.2f} {unit}"
26+
value /= 1024
27+
return f"{num_bytes} B"
28+
29+
30+
def path_size(path):
31+
path = Path(path)
32+
try:
33+
if path.is_file() or path.is_symlink():
34+
return path.stat().st_size
35+
if path.is_dir():
36+
total = 0
37+
for root, dirs, files in os.walk(path):
38+
root_path = Path(root)
39+
dirs[:] = [name for name in dirs if not (root_path / name).is_symlink()]
40+
for filename in files:
41+
try:
42+
total += (root_path / filename).stat().st_size
43+
except OSError:
44+
continue
45+
return total
46+
except OSError:
47+
return 0
48+
return 0
49+
50+
51+
def permanent_delete(path):
52+
if os.path.isdir(path) and not os.path.islink(path):
53+
shutil.rmtree(path)
54+
else:
55+
os.remove(path)
56+
57+
58+
def delete_path_for_manifest(file_path, item_type, direct_delete=False, trash_func=None, delete_func=None):
59+
size_bytes = path_size(file_path)
60+
record = {
61+
"path": str(file_path),
62+
"type": item_type,
63+
"action": "delete" if direct_delete else "trash",
64+
"status": "",
65+
"size_bytes": size_bytes,
66+
"size": human_size(size_bytes),
67+
"error": "",
68+
}
69+
if not os.path.exists(file_path):
70+
record["status"] = "skipped"
71+
record["reason"] = "missing"
72+
return record
73+
if is_protected_file(file_path):
74+
record["status"] = "skipped"
75+
record["reason"] = "protected_extension"
76+
return record
77+
try:
78+
if direct_delete:
79+
(delete_func or permanent_delete)(file_path)
80+
record["status"] = "deleted"
81+
else:
82+
if trash_func is None:
83+
raise RuntimeError("trash_func is not configured")
84+
trash_func(file_path)
85+
record["status"] = "trashed"
86+
except Exception as exc: # noqa: BLE001 - persisted for the cleanup report.
87+
record["status"] = "failed"
88+
record["error"] = str(exc)
89+
return record
90+
91+
92+
def cleanup_result_summary(records, direct_delete=False):
93+
processed = [row for row in records if row.get("status") in {"deleted", "trashed"}]
94+
skipped = [row for row in records if row.get("status") == "skipped"]
95+
failed = [row for row in records if row.get("status") == "failed"]
96+
processed_size = sum(row.get("size_bytes", 0) for row in processed)
97+
return {
98+
"schema_version": 1,
99+
"generated_at": datetime.now().isoformat(timespec="seconds"),
100+
"action": "delete" if direct_delete else "trash",
101+
"direct_delete": direct_delete,
102+
"total_count": len(records),
103+
"processed_count": len(processed),
104+
"skipped_count": len(skipped),
105+
"failed_count": len(failed),
106+
"processed_size_bytes": processed_size,
107+
"processed_size": human_size(processed_size),
108+
"records": records,
109+
}
110+
111+
112+
def combine_cleanup_results(results):
113+
records = []
114+
direct_delete = False
115+
for result in results:
116+
records.extend(result.get("records", []))
117+
direct_delete = direct_delete or bool(result.get("direct_delete"))
118+
return cleanup_result_summary(records, direct_delete=direct_delete)
119+
120+
121+
def write_cleanup_manifest(result, output_dir):
122+
output_dir = Path(output_dir)
123+
output_dir.mkdir(parents=True, exist_ok=True)
124+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
125+
path = output_dir / f"cleanup_manifest_{stamp}.json"
126+
path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
127+
return str(path)

utils/multiDeleteThread.py

Lines changed: 25 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,42 @@
11
import logging
2-
import os
3-
import shutil
42

53
from PyQt5.QtCore import QMutex, QThread, pyqtSignal
64
from send2trash import send2trash
75

8-
9-
PROTECTED_EXTS = {
10-
'.db', '.sqlite', '.sqlite3', '.db-shm', '.db-wal', '.ldb', '.sst',
11-
'.dll', '.exe', '.msi', '.sys', '.ocx', '.pyd', '.so', '.dylib',
12-
'.bat', '.cmd', '.ps1', '.vbs', '.js', '.jar', '.pak'
13-
}
14-
15-
16-
def is_protected_file(file_path):
17-
return os.path.splitext(str(file_path))[1].lower() in PROTECTED_EXTS
6+
from utils.cleanupManifest import cleanup_result_summary, delete_path_for_manifest
187

198

209
qmut = QMutex()
2110

2211

2312
class multiDeleteThread(QThread):
2413
delete_process_signal = pyqtSignal(int)
25-
delete_complete_signal = pyqtSignal()
14+
delete_complete_signal = pyqtSignal(dict)
2615

27-
def __init__(self, fileList, dirList, share_thread_arr, direct_delete=False):
16+
def __init__(self, fileList, dirList, share_thread_arr, direct_delete=False, trash_func=None, delete_func=None):
2817
super(multiDeleteThread, self).__init__()
2918
self.fileList = fileList
3019
self.dirList = dirList
3120
self.share_thread_arr = share_thread_arr
3221
self.direct_delete = direct_delete
33-
34-
def _delete_path(self, file_path):
35-
if is_protected_file(file_path):
36-
logging.info("Skip protected file: %s", file_path)
37-
return
38-
try:
39-
if self.direct_delete:
40-
if os.path.isdir(file_path):
41-
shutil.rmtree(file_path)
42-
else:
43-
os.remove(file_path)
44-
else:
45-
send2trash(file_path)
46-
except Exception:
47-
logging.exception("Failed to delete path: %s", file_path)
22+
self.trash_func = trash_func or send2trash
23+
self.delete_func = delete_func
24+
self.records = []
25+
self.result = cleanup_result_summary([], direct_delete=direct_delete)
26+
27+
def _delete_path(self, file_path, item_type):
28+
record = delete_path_for_manifest(
29+
file_path,
30+
item_type,
31+
direct_delete=self.direct_delete,
32+
trash_func=self.trash_func,
33+
delete_func=self.delete_func,
34+
)
35+
if record["status"] == "skipped":
36+
logging.info("Skip cleanup path: %s (%s)", file_path, record.get("reason", ""))
37+
elif record["status"] == "failed":
38+
logging.error("Failed to delete path: %s: %s", file_path, record.get("error", ""))
39+
return record
4840

4941
def _emit_progress(self):
5042
qmut.lock()
@@ -57,13 +49,14 @@ def _emit_progress(self):
5749
def run(self):
5850
try:
5951
for file_path in self.fileList:
60-
self._delete_path(file_path)
52+
self.records.append(self._delete_path(file_path, "file"))
6153
self._emit_progress()
6254

6355
for file_path in self.dirList:
64-
self._delete_path(file_path)
56+
self.records.append(self._delete_path(file_path, "dir"))
6557
self._emit_progress()
6658

6759
logging.info("Delete thread finished")
6860
finally:
69-
self.delete_complete_signal.emit()
61+
self.result = cleanup_result_summary(self.records, direct_delete=self.direct_delete)
62+
self.delete_complete_signal.emit(self.result)

0 commit comments

Comments
 (0)