save-c 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: save-c
3
+ Version: 1.0.0
4
+ Summary: 将 C 盘目录迁移到 D 盘并创建软链接,安全释放 C 盘空间
5
+ Project-URL: Homepage, https://github.com/user/save-c
6
+ Project-URL: Source, https://github.com/user/save-c
7
+ Author-email: icexmoon <icexmoon@qq.com>
8
+ License: Apache-2.0
9
+ Keywords: disk,space-saver,symlink,windows
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Operating System :: Microsoft :: Windows
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+
20
+ # save-c
21
+
22
+ 将 C 盘目录迁移到 D 盘并创建目录软链接(symlink),安全释放 C 盘空间。
23
+
24
+ 先完整拷贝到 D 盘,确认成功后删除 C 盘原目录,再创建软链接指回 D 盘。
25
+ 拷贝出错时自动清理残缺文件,不会留下脏数据。
26
+
27
+ ## 安装
28
+
29
+ ```bash
30
+ pip install .
31
+ ```
32
+
33
+ 或开发模式安装(编辑后即时生效):
34
+
35
+ ```bash
36
+ pip install -e .
37
+ ```
38
+
39
+ ## 用法
40
+
41
+ ### 交互模式
42
+
43
+ 直接执行 `savec`,按提示输入路径:
44
+
45
+ ```bash
46
+ savec
47
+ ```
48
+
49
+ ### 命令行模式
50
+
51
+ ```
52
+ savec C:\Users\xxx\AppData\Roaming\SomeApp
53
+ ```
54
+
55
+ ### 模拟运行(不做任何实际修改)
56
+
57
+ ```
58
+ savec C:\Users\xxx\SomeDir --dry-run
59
+ ```
60
+
61
+ ### 跳过确认
62
+
63
+ ```
64
+ savec C:\Users\xxx\SomeDir --force
65
+ ```
66
+
67
+ ### 自定义 D 盘保存目录
68
+
69
+ ```
70
+ savec C:\Users\xxx\SomeDir --dest-dir D:\my_moved
71
+ ```
72
+
73
+ ## 扫描模式(批量迁移)
74
+
75
+ 扫描用户目录下的所有子目录,统计占用的磁盘空间,交互式选择要迁移的目录。
76
+
77
+ ```bash
78
+ savec scan
79
+ ```
80
+
81
+ ### 扫描指定目录
82
+
83
+ ```bash
84
+ savec scan -d C:\Users\xxx\AppData
85
+ ```
86
+
87
+ ### 扫描并模拟运行
88
+
89
+ ```bash
90
+ savec scan --dry-run
91
+ ```
92
+
93
+ ### Python API
94
+
95
+ ```python
96
+ from savec import move_and_link
97
+
98
+ move_and_link("C:\\Users\\xxx\\SomeDir", dry_run=True)
99
+
100
+ from savec import scan_and_select_interactive
101
+
102
+ scan_and_select_interactive("C:\\Users\\xxx", dry_run=True)
103
+ ```
104
+
105
+ ## 安全说明
106
+
107
+ - 只允许操作 C 盘目录。
108
+ - 禁止迁移 Windows 系统目录。
109
+ - 拷贝失败自动回滚删除残缺文件。
110
+ - 删除原目录前会二次确认(除非 `--force`)。
111
+ - 创建和删除软链接、删除目录需要**管理员身份**运行。
112
+
113
+ ## 许可证
114
+
115
+ Apache-2.0
116
+
@@ -0,0 +1,9 @@
1
+ savec/__init__.py,sha256=mTo9XpMw0Q3FBCE5jN4lmiQ4y5n2hich9Gu2hh02UPM,322
2
+ savec/__main__.py,sha256=W73yLsFrjelHrDG8yQCzfxKthFZ4mojoFXwAP0DVR-M,97
3
+ savec/cli.py,sha256=a_l6pthR9n35I0TL36uzyYzY0iImvRK7U3G5RX5yRoQ,4602
4
+ savec/core.py,sha256=LmJrtSZ2AWyvo6tGyTIVDbMiNIWKeNLtXhMcV8BCZG8,5203
5
+ savec/scan.py,sha256=crzqZYEOBYcpkZqPTi_jhsNt4Dl0FnwbjcLn-Zt-FNA,11207
6
+ save_c-1.0.0.dist-info/METADATA,sha256=0i-UAjpQl8KU2vmK4EDAqGsX5dX0tp5TTUsK8AvkCdQ,2424
7
+ save_c-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ save_c-1.0.0.dist-info/entry_points.txt,sha256=ihYSbNXD92-OoKsLPO88ih0_8vsUtWtlAXcHoF1NIGo,41
9
+ save_c-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ savec = savec.cli:main
savec/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ save-c — 将 C 盘目录迁移到 D 盘并创建软链接,安全释放 C 盘空间。
3
+ """
4
+
5
+ __version__ = "1.0.0"
6
+
7
+ from savec.core import move_and_link, DryRunError
8
+ from savec.scan import ScanEntry, scan_and_select_interactive
9
+
10
+ __all__ = ["move_and_link", "DryRunError", "ScanEntry", "scan_and_select_interactive"]
savec/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ python -m savec 支持
3
+ """
4
+ from savec.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
savec/cli.py ADDED
@@ -0,0 +1,153 @@
1
+ """
2
+ 命令行入口:``savec`` | ``savec scan`` | ``python -m savec``。
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import os
9
+ import sys
10
+
11
+ from savec import __version__
12
+ from savec.core import DryRunError, move_and_link
13
+ from savec.scan import scan_and_select_interactive
14
+
15
+
16
+ def _build_move_parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="savec",
19
+ description="将 C 盘目录迁移到 D 盘并创建软链接,安全释放 C 盘空间。",
20
+ epilog=(
21
+ "不传参时进入交互模式,由程序提示输入路径。\n"
22
+ "注意:创建和删除软链接、删除目录需要管理员权限。\n"
23
+ "子命令: savec scan — 扫描用户目录并交互式迁移"
24
+ ),
25
+ )
26
+ parser.add_argument(
27
+ "path",
28
+ nargs="?",
29
+ default=None,
30
+ help="C 盘待迁移的目录完整路径(如 C:\\Users\\xxx\\AppData\\Roaming\\SomeApp)。",
31
+ )
32
+ parser.add_argument(
33
+ "--dest-dir",
34
+ default="D:\\moved_from_c",
35
+ help="D 盘目标根目录(默认: D:\\moved_from_c)。",
36
+ )
37
+ parser.add_argument(
38
+ "--dry-run",
39
+ action="store_true",
40
+ help="模拟运行,打印动作日志但不做任何实际修改。",
41
+ )
42
+ parser.add_argument(
43
+ "-f", "--force",
44
+ action="store_true",
45
+ help="跳过二次确认提示。",
46
+ )
47
+ parser.add_argument(
48
+ "-V", "--version",
49
+ action="version",
50
+ version=f"save-c {__version__}",
51
+ help="显示版本信息并退出。",
52
+ )
53
+ return parser
54
+
55
+
56
+ def _build_scan_parser() -> argparse.ArgumentParser:
57
+ parser = argparse.ArgumentParser(
58
+ prog="savec scan",
59
+ description="扫描目录,统计可迁移的空间并交互式选择迁移。",
60
+ )
61
+ parser.add_argument(
62
+ "-d", "--dir",
63
+ default=os.path.expanduser("~"),
64
+ help="要扫描的目录(默认: 当前用户主目录)。",
65
+ )
66
+ parser.add_argument(
67
+ "--dest-dir",
68
+ default="D:\\moved_from_c",
69
+ help="D 盘目标根目录(默认: D:\\moved_from_c)。",
70
+ )
71
+ parser.add_argument(
72
+ "--dry-run",
73
+ action="store_true",
74
+ help="模拟运行,不做实际修改。",
75
+ )
76
+ parser.add_argument(
77
+ "--min-size",
78
+ type=int,
79
+ default=500,
80
+ metavar="MB",
81
+ help="只显示大于指定大小(MB)的目录(默认: 500MB)。",
82
+ )
83
+ parser.add_argument(
84
+ "--no-cache",
85
+ action="store_true",
86
+ help="忽略缓存,强制重新扫描。",
87
+ )
88
+ return parser
89
+
90
+
91
+ def _run_move(argv: list[str]) -> int:
92
+ """迁移模式:处理单个目录迁移。"""
93
+ parser = _build_move_parser()
94
+ args = parser.parse_args(argv)
95
+
96
+ if args.path is None:
97
+ print("===== save-c: C盘目录迁移 + 软链接(安全增强版)=====")
98
+ print("说明:先完整复制,成功后再删原目录,异常自动清理残缺文件\n")
99
+ raw = input("请输入要迁移的 C 盘完整目录路径: ").strip().strip('"')
100
+ if not raw:
101
+ print("[CANCEL] 未输入路径,退出。")
102
+ return 0
103
+ args.path = raw
104
+
105
+ try:
106
+ move_and_link(
107
+ args.path,
108
+ dst_dir=args.dest_dir,
109
+ dry_run=args.dry_run,
110
+ skip_confirm=args.force,
111
+ )
112
+ return 0
113
+ except DryRunError:
114
+ return 0
115
+ except PermissionError:
116
+ return 1
117
+ except (ValueError, OSError) as exc:
118
+ print(f"错误: {exc}")
119
+ return 1
120
+
121
+
122
+ def _run_scan(argv: list[str]) -> int:
123
+ """扫描模式:交互式扫描、选择并迁移。"""
124
+ parser = _build_scan_parser()
125
+ args = parser.parse_args(argv)
126
+ return scan_and_select_interactive(
127
+ args.dir,
128
+ dest_dir=args.dest_dir,
129
+ dry_run=args.dry_run,
130
+ min_size=args.min_size * 1024 * 1024,
131
+ no_cache=args.no_cache,
132
+ )
133
+
134
+
135
+ def main(argv: list[str] | None = None) -> int:
136
+ if argv is None:
137
+ argv = sys.argv[1:]
138
+
139
+ if argv and argv[0] == "scan":
140
+ return _run_scan(argv[1:])
141
+ elif argv and argv[0] == "--help":
142
+ _build_move_parser().print_help()
143
+ return 0
144
+ elif argv and argv[0] in ("-V", "--version"):
145
+ _build_move_parser().parse_args(argv)
146
+ return 0
147
+ else:
148
+ return _run_move(argv)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ sys.exit(main())
153
+
savec/core.py ADDED
@@ -0,0 +1,166 @@
1
+ """
2
+ 核心逻辑:目录安全拷贝、删除、软链接创建。
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import shutil
9
+ import traceback
10
+
11
+
12
+ class DryRunError(RuntimeError):
13
+ """Dry-run 模式下触发的中止信号,不会真正写入磁盘。"""
14
+
15
+
16
+ # 禁止移动的系统核心目录前缀
17
+ FORBIDDEN_PREFIXES = [
18
+ "C:\\Windows",
19
+ "C:\\Program Files",
20
+ "C:\\Program Files (x86)",
21
+ "C:\\System",
22
+ "C:\\Boot",
23
+ "C:\\PerfLogs",
24
+ ]
25
+
26
+
27
+ def safe_copy_dir(src: str, dst: str, *, dry_run: bool = False) -> bool:
28
+ """安全完整拷贝目录,出错则回滚删除目标。
29
+
30
+ Args:
31
+ src: 源目录路径。
32
+ dst: 目标目录路径。
33
+ dry_run: 仅打印模拟信息,不实际拷贝。
34
+
35
+ Returns:
36
+ True 拷贝成功;False 目标已存在或拷贝失败。
37
+
38
+ Raises:
39
+ DryRunError: dry_run 模式下触发,调用方据此跳过后续实际写入。
40
+ """
41
+ if os.path.exists(dst):
42
+ print(f" [X] 目标目录已存在: {dst}")
43
+ return False
44
+
45
+ if dry_run:
46
+ print(f" [DRY] 模拟拷贝: {src} -> {dst}")
47
+ return True
48
+
49
+ try:
50
+ shutil.copytree(src, dst, dirs_exist_ok=False)
51
+ print(f" [OK] 目录拷贝完成: {dst}")
52
+ return True
53
+ except Exception as e:
54
+ print(f" [X] 拷贝过程出错: {e}")
55
+ if os.path.exists(dst):
56
+ try:
57
+ shutil.rmtree(dst)
58
+ print(f" [CLEAN] 已清理残缺拷贝目录: {dst}")
59
+ except Exception:
60
+ print(f" [WARN] 清理残缺目录失败,请手动删除: {dst}")
61
+ return False
62
+
63
+
64
+ def move_and_link(
65
+ src: str,
66
+ *,
67
+ dst_dir: str = "D:\\moved_from_c",
68
+ dry_run: bool = False,
69
+ skip_confirm: bool = False,
70
+ ) -> None:
71
+ """流程:校验 -> 拷贝 -> 删原目录 -> 创建软链接。
72
+
73
+ 异常自动回滚,提升安全性。
74
+
75
+ Args:
76
+ src: C 盘源目录完整路径。
77
+ dst_dir: D 盘目标根目录,默认 ``D:\\moved_from_c``。
78
+ dry_run: 仅打印模拟动作,不实际写入磁盘。
79
+ skip_confirm: 跳过二次确认提示。
80
+
81
+ Raises:
82
+ ValueError: 路径校验失败(非 C 盘、系统目录等)。
83
+ DryRunError: dry_run 模式完成全部模拟后抛出,表示流程正常中止。
84
+ PermissionError: 权限不足(需要管理员身份)。
85
+ """
86
+ src = src.rstrip("\\/")
87
+
88
+ # ── 源路径检查 ──
89
+ if not os.path.exists(src):
90
+ raise ValueError(f"源目录不存在: {src}")
91
+ if not os.path.isdir(src):
92
+ raise ValueError(f"不是有效目录: {src}")
93
+ if not src.startswith("C:\\"):
94
+ raise ValueError("仅支持转移 C 盘目录!")
95
+
96
+ src_lower = src.lower()
97
+ for fp in FORBIDDEN_PREFIXES:
98
+ if src_lower.startswith(fp.lower()):
99
+ raise ValueError(f"禁止转移系统关键目录: {src}")
100
+
101
+ # ── 构造目标路径 ──
102
+ rel_path = os.path.relpath(src, "C:\\")
103
+ dst_full = os.path.join(dst_dir, rel_path)
104
+
105
+ print("=" * 60)
106
+ print(f" 源目录: {src}")
107
+ print(f" 目标目录: {dst_full}")
108
+ print("=" * 60)
109
+
110
+ # ── 二次确认 ──
111
+ if not skip_confirm:
112
+ try:
113
+ answer = input("确认执行拷贝+迁移?(y/n): ").strip().lower()
114
+ except (EOFError, KeyboardInterrupt):
115
+ print()
116
+ print("[CANCEL] 操作已取消")
117
+ return
118
+ if answer != "y":
119
+ print("[CANCEL] 操作已取消")
120
+ return
121
+
122
+ # ── 执行流程 ──
123
+ try:
124
+ # 1. 创建 D 盘总目录
125
+ if not dry_run:
126
+ os.makedirs(dst_dir, exist_ok=True)
127
+ else:
128
+ print(f" [DRY] 创建目录: {dst_dir}")
129
+
130
+ # 2. 完整拷贝,失败自动回滚
131
+ print("\n[PACK] 开始完整拷贝文件...")
132
+ copy_ok = safe_copy_dir(src, dst_full, dry_run=dry_run)
133
+ if not copy_ok:
134
+ print("[X] 拷贝失败,终止流程")
135
+ return
136
+
137
+ # 3. 拷贝成功后,删除原 C 盘目录
138
+ if dry_run:
139
+ print(f" [DRY] 删除原目录: {src}")
140
+ else:
141
+ print("\n[DEL] 删除原 C 盘目录...")
142
+ shutil.rmtree(src)
143
+ print(f" [OK] 原目录已删除: {src}")
144
+
145
+ # 4. 创建目录软链接
146
+ if dry_run:
147
+ print(f" [DRY] 创建软链接: {src} -> {dst_full}")
148
+ raise DryRunError("Dry-run 完成,未做任何实际修改")
149
+ else:
150
+ print("\n[LINK] 创建软链接...")
151
+ os.symlink(dst_full, src, target_is_directory=True)
152
+ print(f" [OK] 软链接创建成功: {src} -> {dst_full}")
153
+
154
+ print("\n[DONE] 全部操作完成,C 盘空间已释放!")
155
+
156
+ except PermissionError:
157
+ print("\n[X] 权限不足!请以管理员身份运行。")
158
+ raise
159
+ except DryRunError:
160
+ raise
161
+ except Exception as e:
162
+ print(f"\n[X] 执行异常: {e}")
163
+ traceback.print_exc()
164
+ if os.path.exists(dst_full) and not os.path.exists(src):
165
+ print(f"[WARN] 异常中断,请手动检查:\n 源目录: {src}\n 备份目录: {dst_full}")
166
+ raise
savec/scan.py ADDED
@@ -0,0 +1,338 @@
1
+ """
2
+ 扫描 C 盘用户目录,统计可迁移的目录及空间占用。
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import sys
11
+ import time
12
+ from dataclasses import dataclass
13
+
14
+ from savec.core import DryRunError, move_and_link
15
+
16
+ # 扫描时忽略的特殊目录(用户主目录下的系统级目录)
17
+ SKIP_DIRS = frozenset({"appdata", "onedrive", "documents"})
18
+
19
+ CACHE_DIR = os.path.join(os.environ.get("TEMP", os.environ.get("TMPDIR", "/tmp")), "savec-scan-cache")
20
+ CACHE_TTL = 600
21
+
22
+
23
+ def _cache_path(base_dir):
24
+ h = hashlib.sha256(base_dir.encode("utf-8")).hexdigest()[:16]
25
+ os.makedirs(CACHE_DIR, exist_ok=True)
26
+ return os.path.join(CACHE_DIR, f"{h}.json")
27
+
28
+
29
+ def _load_cache(base_dir):
30
+ cpath = _cache_path(base_dir)
31
+ if not os.path.isfile(cpath):
32
+ return None
33
+ try:
34
+ with open(cpath, "r", encoding="utf-8") as f:
35
+ data = json.load(f)
36
+ if time.time() - data["created_at"] > CACHE_TTL:
37
+ return None
38
+ entries = [ScanEntry(**e) for e in data["entries"]]
39
+ return entries, data["symlink_count"]
40
+ except (json.JSONDecodeError, KeyError, OSError):
41
+ return None
42
+
43
+
44
+ def _save_cache(base_dir, entries, symlink_count):
45
+ cpath = _cache_path(base_dir)
46
+ data = {
47
+ "base_dir": base_dir,
48
+ "created_at": time.time(),
49
+ "symlink_count": symlink_count,
50
+ "entries": [
51
+ {"name": e.name, "path": e.path, "size_bytes": e.size_bytes, "is_symlink": e.is_symlink}
52
+ for e in entries
53
+ ],
54
+ }
55
+ try:
56
+ with open(cpath, "w", encoding="utf-8") as f:
57
+ json.dump(data, f, ensure_ascii=False)
58
+ except OSError:
59
+ pass
60
+
61
+
62
+ @dataclass
63
+ class ScanEntry:
64
+ """单个扫描结果。"""
65
+ name: str
66
+ path: str
67
+ size_bytes: int
68
+ is_symlink: bool
69
+
70
+
71
+ def format_size(size_bytes: int) -> str:
72
+ """将字节格式化为可读尺寸。"""
73
+ if size_bytes == 0:
74
+ return " 0 B"
75
+ b = float(abs(size_bytes))
76
+ for unit in ("B", "KB", "MB", "GB", "TB"):
77
+ if b < 1024.0:
78
+ return f"{b:>6.1f} {unit}"
79
+ b /= 1024.0
80
+ return f"{b:>6.1f} PB"
81
+
82
+
83
+ def _list_dirs(base_dir: str) -> tuple[list[ScanEntry], int]:
84
+ """列出 base_dir 下的所有子目录,分辨软链接。
85
+
86
+ Returns:
87
+ (entries, symlink_count)
88
+ """
89
+ entries: list[ScanEntry] = []
90
+ symlink_count = 0
91
+
92
+ try:
93
+ with os.scandir(base_dir) as it:
94
+ for entry in it:
95
+ if not entry.is_dir(follow_symlinks=False):
96
+ continue
97
+ is_link = os.path.islink(entry.path)
98
+ entries.append(ScanEntry(
99
+ name=entry.name,
100
+ path=entry.path,
101
+ size_bytes=0,
102
+ is_symlink=is_link,
103
+ ))
104
+ if is_link:
105
+ symlink_count += 1
106
+ except PermissionError:
107
+ print(f" [WARN] 无权限访问: {base_dir}")
108
+ except FileNotFoundError:
109
+ print(f" [X] 目录不存在: {base_dir}")
110
+
111
+ return entries, symlink_count
112
+
113
+
114
+ def _calc_dir_size(dirpath: str, *, report_name: str = "") -> int:
115
+ """递归计算目录大小。
116
+
117
+ 跳过内部软链接子目录,避免重复计算和无限递归。
118
+ """
119
+ total = 0
120
+ try:
121
+ for root, dirs, files in os.walk(dirpath, followlinks=False):
122
+ dirs[:] = [d for d in dirs
123
+ if not os.path.islink(os.path.join(root, d))]
124
+ for f in files:
125
+ fpath = os.path.join(root, f)
126
+ try:
127
+ if not os.path.islink(fpath):
128
+ total += os.path.getsize(fpath)
129
+ except (OSError, PermissionError):
130
+ pass
131
+ except (OSError, PermissionError):
132
+ pass
133
+ return total
134
+
135
+
136
+ def scan_and_select_interactive(
137
+ base_dir: str,
138
+ *,
139
+ dest_dir: str = "D:\\moved_from_c",
140
+ dry_run: bool = False,
141
+ min_size: int = 500 * 1024 * 1024,
142
+ no_cache: bool = False,
143
+ ) -> int:
144
+ """扫描、展示、交互式选择并迁移目录。
145
+
146
+ Returns:
147
+ 0 正常结束;1 出错。
148
+ """
149
+ base_dir = base_dir.rstrip("\\/")
150
+ if not os.path.isdir(base_dir):
151
+ print(f"[X] 目录不存在: {base_dir}")
152
+ return 1
153
+
154
+ # -- 0. 尝试读取缓存 --
155
+ loaded_from_cache = False
156
+ if not no_cache:
157
+ cached = _load_cache(base_dir)
158
+ if cached is not None:
159
+ all_entries, symlink_count = cached
160
+ loaded_from_cache = True
161
+ print(f"正在扫描 {base_dir} ...")
162
+ print(" 使用缓存结果(10分钟内有效)")
163
+
164
+ if not loaded_from_cache:
165
+ # -- 1. 列出所有子目录 --
166
+ print(f"正在扫描 {base_dir} ...")
167
+ all_entries, symlink_count = _list_dirs(base_dir)
168
+
169
+ if not all_entries:
170
+ print(" 未发现任何子目录。")
171
+ return 0
172
+
173
+ # -- 过滤掉不需要扫描的特殊目录 --
174
+ skip_count = 0
175
+ filtered = []
176
+ for e in all_entries:
177
+ if e.name.lower() in SKIP_DIRS:
178
+ skip_count += 1
179
+ continue
180
+ filtered.append(e)
181
+ all_entries = filtered
182
+
183
+ # -- 额外扫描 AppData\Local 和 AppData\Roaming --
184
+ appdata_extra = 0
185
+ for rel in ("AppData\\Local", "AppData\\Roaming"):
186
+ sub_dir = os.path.join(base_dir, rel)
187
+ if os.path.isdir(sub_dir):
188
+ sub_entries, sub_links = _list_dirs(sub_dir)
189
+ for e in sub_entries:
190
+ if not e.is_symlink:
191
+ e.name = f"{rel}\\{e.name}"
192
+ all_entries.extend(sub_entries)
193
+ symlink_count += sub_links
194
+ appdata_extra += len(sub_entries)
195
+
196
+ if not all_entries:
197
+ if skip_count and not appdata_extra:
198
+ print(f" 扫描完成,仅剩余 {skip_count} 个已过滤的目录,无需处理。")
199
+ return 0
200
+ elif not skip_count and not appdata_extra:
201
+ print(" 未发现任何子目录。")
202
+ return 0
203
+
204
+ if skip_count:
205
+ print(f" 已过滤 {skip_count} 个特殊目录")
206
+ if appdata_extra:
207
+ print(f" 额外从 AppData\\Local 和 AppData\\Roaming 扫描到 {appdata_extra} 个子目录")
208
+
209
+ # -- 2. 分离已迁移(软链接)和待扫描目录 --
210
+ to_scan = [e for e in all_entries if not e.is_symlink]
211
+ if not to_scan:
212
+ print(f" 所有子目录均已迁移(共 {symlink_count} 个软链接),无需处理。")
213
+ return 0
214
+
215
+ print(f" 共 {len(all_entries)} 个子目录,已跳过 {symlink_count} 个已迁移目录(软链接)")
216
+
217
+ # -- 3. 计算每个目录大小 --
218
+ total_dirs = len(to_scan)
219
+ print(f" 正在统计目录大小 ... 0/{total_dirs}", end="", flush=True)
220
+ for i, entry in enumerate(to_scan, 1):
221
+ entry.size_bytes = _calc_dir_size(entry.path)
222
+ sys.stdout.write(f"\r 正在统计目录大小 ... {i}/{total_dirs} {entry.name} {format_size(entry.size_bytes)} ")
223
+ sys.stdout.flush()
224
+ print()
225
+
226
+ _save_cache(base_dir, all_entries, symlink_count)
227
+
228
+ else:
229
+ to_scan = [e for e in all_entries if not e.is_symlink]
230
+ if not to_scan:
231
+ print(" 所有子目录均已迁移,无需处理。")
232
+ return 0
233
+ print(f" 共 {len(all_entries)} 个子目录,已跳过 {symlink_count} 个已迁移目录(软链接)")
234
+
235
+ # -- 4. 按大小降序排列 --
236
+ # ── 4. 按大小降序排列 ──
237
+ to_scan.sort(key=lambda e: e.size_bytes, reverse=True)
238
+
239
+ # ── 过滤小于 min_size 的目录 ──
240
+ hidden_count = 0
241
+ hidden_total = 0
242
+ filtered: list[ScanEntry] = []
243
+ for e in to_scan:
244
+ if e.size_bytes < min_size:
245
+ hidden_count += 1
246
+ hidden_total += e.size_bytes
247
+ else:
248
+ filtered.append(e)
249
+ to_scan = filtered
250
+
251
+ if hidden_count:
252
+ print(f" 已忽略 {hidden_count} 个小于 {format_size(min_size)} 的目录(合计 {format_size(hidden_total)})")
253
+
254
+ if not to_scan:
255
+ print(" 所有目录均小于阈值,无需处理。")
256
+ return 0
257
+
258
+ # ── 5. 展示结果 ──
259
+ print()
260
+ print(f" {'':>4} {'大小':>10} 目录")
261
+ print(f" {'':─>4} {'─'*10} {'─'*50}")
262
+ for idx, entry in enumerate(to_scan, 1):
263
+ size_str = format_size(entry.size_bytes)
264
+ print(f" {idx:>3} {size_str} {entry.name}")
265
+
266
+ total_size = sum(e.size_bytes for e in to_scan)
267
+ print(f" {'':─>4} {'─'*10} {'─'*50}")
268
+ print(f" {format_size(total_size)} 合计")
269
+ print()
270
+
271
+ # ── 6. 用户选择 ──
272
+ chosen = _select_entries(to_scan)
273
+ if not chosen:
274
+ print("[CANCEL] 未选择任何目录,退出。")
275
+ return 0
276
+
277
+ # ── 7. 逐个迁移 ──
278
+ success = 0
279
+ fail = 0
280
+ chosen_size = sum(e.size_bytes for e in chosen)
281
+ print(f"\n开始迁移 {len(chosen)} 个目录(合计 {format_size(chosen_size)})...\n")
282
+ for entry in chosen:
283
+ print(f"{'='*60}")
284
+ print(f" 处理: {entry.path}")
285
+ try:
286
+ move_and_link(entry.path, dst_dir=dest_dir, dry_run=dry_run, skip_confirm=True)
287
+ success += 1
288
+ except DryRunError:
289
+ success += 1
290
+ except (ValueError, PermissionError, OSError) as exc:
291
+ print(f" [X] 跳过: {exc}")
292
+ fail += 1
293
+ print()
294
+
295
+ # ── 8. 汇总 ──
296
+ if dry_run:
297
+ print(f"\n[DONE] 模拟完成。成功: {success}, 失败: {fail}")
298
+ else:
299
+ print(f"\n[DONE] 迁移完成。成功: {success}, 失败: {fail}")
300
+ return 0
301
+
302
+
303
+ def _select_entries(entries: list[ScanEntry]) -> list[ScanEntry]:
304
+ """交互式选择目录编号,返回选中的条目列表。"""
305
+ prompt = "输入编号(空格分隔,如 1 3 5),a=全部,q=取消: "
306
+ try:
307
+ raw = input(prompt).strip().lower()
308
+ except (EOFError, KeyboardInterrupt):
309
+ print()
310
+ return []
311
+
312
+ if not raw or raw == "q":
313
+ return []
314
+ if raw == "a":
315
+ return list(entries)
316
+
317
+ indices: set[int] = set()
318
+ for part in raw.replace(",", " ").split():
319
+ if "-" in part:
320
+ try:
321
+ a, b = part.split("-", 1)
322
+ start = int(a.strip())
323
+ end = int(b.strip())
324
+ indices.update(range(start, end + 1))
325
+ except ValueError:
326
+ continue
327
+ else:
328
+ try:
329
+ indices.add(int(part))
330
+ except ValueError:
331
+ continue
332
+
333
+ result: list[ScanEntry] = []
334
+ for idx in sorted(indices):
335
+ if 1 <= idx <= len(entries):
336
+ result.append(entries[idx - 1])
337
+ return result
338
+