modmgr 0.0.1__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.
Files changed (50) hide show
  1. modmgr/__init__.py +16 -0
  2. modmgr/acf_parser.py +302 -0
  3. modmgr/apply_ops.py +145 -0
  4. modmgr/backup_dir_builder.py +218 -0
  5. modmgr/backup_ops.py +657 -0
  6. modmgr/bootstrap.py +264 -0
  7. modmgr/cli.py +509 -0
  8. modmgr/core/__init__.py +1 -0
  9. modmgr/core/workspacemanager.py +219 -0
  10. modmgr/database_ops.py +621 -0
  11. modmgr/engine.py +740 -0
  12. modmgr/forest_visual.py +983 -0
  13. modmgr/iojson.py +26 -0
  14. modmgr/orchestrator/__init__.py +308 -0
  15. modmgr/orchestrator/_common.py +95 -0
  16. modmgr/orchestrator/compute_pipeline.py +232 -0
  17. modmgr/orchestrator/entry.py +38 -0
  18. modmgr/orchestrator/ignore_rules.py +141 -0
  19. modmgr/orchestrator/planner_fileops.py +151 -0
  20. modmgr/orchestrator/preflight.py +93 -0
  21. modmgr/orchestrator/resolver.py +102 -0
  22. modmgr/path_resolver.py +184 -0
  23. modmgr/paths.py +85 -0
  24. modmgr/pathstyle.py +111 -0
  25. modmgr/prep.py +115 -0
  26. modmgr/restore_ops.py +221 -0
  27. modmgr/rule_aggregator.py +591 -0
  28. modmgr/schema.py +49 -0
  29. modmgr/steam_scanner.py +395 -0
  30. modmgr/validation.py +216 -0
  31. modmgr/vdf_parser.py +113 -0
  32. modmgr-0.0.1.dist-info/METADATA +95 -0
  33. modmgr-0.0.1.dist-info/RECORD +50 -0
  34. modmgr-0.0.1.dist-info/WHEEL +5 -0
  35. modmgr-0.0.1.dist-info/entry_points.txt +2 -0
  36. modmgr-0.0.1.dist-info/licenses/LICENSE +7 -0
  37. modmgr-0.0.1.dist-info/top_level.txt +2 -0
  38. modmgr_web/__init__.py +10 -0
  39. modmgr_web/__main__.py +29 -0
  40. modmgr_web/adapters.py +105 -0
  41. modmgr_web/app.py +79 -0
  42. modmgr_web/routes/__init__.py +1 -0
  43. modmgr_web/routes/backups.py +147 -0
  44. modmgr_web/routes/config.py +73 -0
  45. modmgr_web/routes/database.py +99 -0
  46. modmgr_web/routes/pipeline.py +158 -0
  47. modmgr_web/routes/rules.py +369 -0
  48. modmgr_web/routes/workspace.py +379 -0
  49. modmgr_web/schemas.py +184 -0
  50. modmgr_web/sse.py +90 -0
modmgr/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """modmanager package."""
2
+
3
+ __all__ = [
4
+ "engine",
5
+ "pathstyle",
6
+ "paths",
7
+ "iojson",
8
+ "database_ops",
9
+ "schema",
10
+ "validation",
11
+ "rule_aggregator",
12
+ "backup_ops",
13
+ "forest_visual",
14
+ "bootstrap",
15
+ "orchestrator",
16
+ ]
modmgr/acf_parser.py ADDED
@@ -0,0 +1,302 @@
1
+ """ACF file format parser for Steam workshop mods.
2
+
3
+ ACF (Valve Application Configuration Format) is similar to VDF.
4
+ Used for appmanifest_*.acf (game metadata) and appworkshop_*.acf (workshop mods).
5
+
6
+ Example appmanifest structure:
7
+ "AppState"
8
+ {
9
+ "appid" "270150"
10
+ "name" "Running with Rifles"
11
+ "StateFlags" "4"
12
+ "LastUpdated" "..."
13
+ ...
14
+ }
15
+
16
+ Example appworkshop structure:
17
+ "WorkshopItemDetails"
18
+ {
19
+ "0" { "publishedfileid" "2606099273" ... }
20
+ "1" { "publishedfileid" "3428584891" ... }
21
+ ...
22
+ }
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from pathlib import Path
28
+ from typing import Any
29
+ import vdf
30
+
31
+
32
+ def parse_appmanifest_acf(acf_path: str) -> dict[str, Any]:
33
+ """
34
+ Parse appmanifest_*.acf to get game information.
35
+
36
+ Args:
37
+ acf_path: Path to appmanifest_*.acf file
38
+
39
+ Returns:
40
+ {
41
+ "appid": "270150",
42
+ "name": "Running with Rifles",
43
+ "installdir": "Running with Rifles",
44
+ ...
45
+ }
46
+
47
+ Raises:
48
+ FileNotFoundError: If file does not exist
49
+ ValueError: If ACF format is invalid
50
+ """
51
+ acf_file = Path(acf_path)
52
+ if not acf_file.exists():
53
+ raise FileNotFoundError(f"ACF file not found: {acf_path}")
54
+
55
+ try:
56
+ with open(acf_file, "r", encoding="utf-8") as f:
57
+ data = vdf.load(f)
58
+ except Exception as e:
59
+ raise ValueError(f"Failed to parse ACF file: {e}")
60
+
61
+ app_state = data.get("AppState", {})
62
+ if isinstance(app_state, dict):
63
+ return dict(app_state)
64
+ else:
65
+ return {}
66
+
67
+
68
+ def parse_appworkshop_acf(acf_path: str) -> list[dict[str, Any]]:
69
+ """
70
+ Parse appworkshop_*.acf to get workshop mod information.
71
+
72
+ Args:
73
+ acf_path: Path to appworkshop_*.acf file (e.g., appworkshop_270150.acf)
74
+
75
+ Returns:
76
+ List of mod metadata:
77
+ [
78
+ {
79
+ "publishedfileid": "2606099273",
80
+ "installed": True,
81
+ "workingbuild": "...",
82
+ ...
83
+ },
84
+ ...
85
+ ]
86
+
87
+ Raises:
88
+ FileNotFoundError: If file does not exist
89
+ ValueError: If ACF format is invalid
90
+ """
91
+ acf_file = Path(acf_path)
92
+ if not acf_file.exists():
93
+ raise FileNotFoundError(f"ACF file not found: {acf_path}")
94
+
95
+ try:
96
+ with open(acf_file, "r", encoding="utf-8") as f:
97
+ data = vdf.load(f)
98
+ except Exception as e:
99
+ raise ValueError(f"Failed to parse ACF file: {e}")
100
+
101
+ items = []
102
+ workshop_items = data.get("WorkshopItemDetails", {})
103
+
104
+ if isinstance(workshop_items, dict):
105
+ # Sort by numeric keys
106
+ sorted_keys = sorted(
107
+ [k for k in workshop_items.keys() if k.isdigit()],
108
+ key=int
109
+ )
110
+
111
+ for key in sorted_keys:
112
+ entry = workshop_items[key]
113
+ if isinstance(entry, dict):
114
+ items.append(dict(entry))
115
+
116
+ return items
117
+
118
+
119
+ def find_appmanifest_acf_files(steamapps_path: str) -> dict[str, str]:
120
+ """
121
+ Find all appmanifest_*.acf files in steamapps directory.
122
+
123
+ Args:
124
+ steamapps_path: Path to steamapps/ directory
125
+
126
+ Returns:
127
+ Dict: {appid: path_to_appmanifest_acf}
128
+ Example: {"270150": "/path/to/appmanifest_270150.acf", ...}
129
+ """
130
+ raise NotImplementedError("To be implemented in M1.1")
131
+
132
+
133
+ def find_appworkshop_acf_files(steamapps_path: str) -> dict[str, str]:
134
+ """
135
+ Find all appworkshop_*.acf files in steamapps directory.
136
+
137
+ Args:
138
+ steamapps_path: Path to steamapps/ directory
139
+
140
+ Returns:
141
+ Dict: {appid: path_to_appworkshop_acf}
142
+ Example: {"270150": "/path/to/appworkshop_270150.acf", ...}
143
+ """
144
+ raise NotImplementedError("To be implemented in M1.1")
145
+
146
+
147
+ def get_workshop_timeupdated(steamapps_path: str, appid: str, contentid: str | None = None) -> str:
148
+ """从 appworkshop_{appid}.acf 读取 timeupdated 字段并返回字符串。
149
+
150
+ ACF 文件位于 ``steamapps_path/workshop/`` 子目录下。
151
+
152
+ 若传入 contentid,则优先在 WorkshopItemsInstalled 和 WorkshopItemDetails
153
+ 中查找指定 contentid 的 timeupdated,找到即返回(不降级到其他策略)。
154
+
155
+ 若 contentid 为 None,使用原有策略(按优先级):
156
+ 1. ``AppWorkshop.TimeLastUpdated`` 顶层时间戳
157
+ 2. ``AppWorkshop.WorkshopItemsInstalled.*.timeupdated``
158
+ 3. ``AppWorkshop.WorkshopItemDetails.*.timeupdated`` 取最大值
159
+
160
+ 若文件不存在或所有字段缺失 → 返回 "0"。
161
+
162
+ Returns:
163
+ timeupdated 的字符串(数字字符串),或 "0"
164
+ """
165
+ acf_path = Path(steamapps_path) / "workshop" / f"appworkshop_{appid}.acf"
166
+ if not acf_path.exists():
167
+ return "0"
168
+
169
+ try:
170
+ with open(acf_path, "r", encoding="utf-8") as f:
171
+ data = vdf.load(f)
172
+ except Exception:
173
+ return "0"
174
+
175
+ if not isinstance(data, dict):
176
+ return "0"
177
+
178
+ # Unwrap the top-level "AppWorkshop" key
179
+ app_ws = data.get("AppWorkshop")
180
+ if isinstance(app_ws, dict):
181
+ ws_data = app_ws
182
+ else:
183
+ ws_data = data
184
+
185
+ # If a specific contentid is requested, do targeted lookup first
186
+ if contentid is not None:
187
+ # WorkshopItemsInstalled -> contentid -> timeupdated
188
+ try:
189
+ installed = ws_data.get("WorkshopItemsInstalled")
190
+ if isinstance(installed, dict):
191
+ app_entry = installed.get(contentid)
192
+ if isinstance(app_entry, dict):
193
+ tu = app_entry.get("timeupdated")
194
+ if tu is not None:
195
+ return str(tu)
196
+ except (ValueError, TypeError):
197
+ pass
198
+
199
+ # WorkshopItemDetails -> contentid -> timeupdated (fallback)
200
+ try:
201
+ details = ws_data.get("WorkshopItemDetails")
202
+ if isinstance(details, dict):
203
+ entry = details.get(contentid)
204
+ if isinstance(entry, dict):
205
+ tu = entry.get("timeupdated")
206
+ if tu is not None:
207
+ return str(tu)
208
+ except (ValueError, TypeError):
209
+ pass
210
+
211
+ return "0"
212
+
213
+ # ── contentid is None: use legacy multi-source strategy ──────────────
214
+ timestamps: list[int] = []
215
+
216
+ # 1. WorkshopItemsInstalled -> <appid> -> timeupdated
217
+ try:
218
+ installed = ws_data.get("WorkshopItemsInstalled")
219
+ if isinstance(installed, dict):
220
+ app_entry = installed.get(appid)
221
+ if isinstance(app_entry, dict):
222
+ tu = app_entry.get("timeupdated")
223
+ if tu is not None:
224
+ timestamps.append(int(str(tu)))
225
+ except (ValueError, TypeError):
226
+ pass
227
+
228
+ # 2. WorkshopItemDetails -> <index> -> timeupdated
229
+ try:
230
+ details = ws_data.get("WorkshopItemDetails")
231
+ if isinstance(details, dict):
232
+ for _key, entry in details.items():
233
+ if isinstance(entry, dict):
234
+ tu = entry.get("timeupdated")
235
+ if tu is not None:
236
+ timestamps.append(int(str(tu)))
237
+ except (ValueError, TypeError):
238
+ pass
239
+
240
+ if timestamps:
241
+ return str(max(timestamps))
242
+
243
+ # 3. Fallback: top-level TimeLastUpdated (e.g. "1778139487")
244
+ try:
245
+ tlu = ws_data.get("TimeLastUpdated")
246
+ if tlu is not None:
247
+ return str(int(str(tlu)))
248
+ except (ValueError, TypeError):
249
+ pass
250
+
251
+ return "0"
252
+
253
+
254
+ def get_workshop_latest_timeupdated(steamapps_path: str, appid: str, contentid: str) -> str:
255
+ """从 appworkshop_{appid}.acf 读取特定 contentid 的 latest_timeupdated。
256
+
257
+ 读取 ``steamapps_path/workshop/appworkshop_{appid}.acf``,
258
+ 解包 AppWorkshop,查 WorkshopItemDetails.{contentid}.latest_timeupdated。
259
+
260
+ Returns:
261
+ latest_timeupdated 的字符串,或 "0"
262
+ """
263
+ acf_path = Path(steamapps_path) / "workshop" / f"appworkshop_{appid}.acf"
264
+ if not acf_path.exists():
265
+ return "0"
266
+
267
+ try:
268
+ with open(acf_path, "r", encoding="utf-8") as f:
269
+ data = vdf.load(f)
270
+ except Exception:
271
+ return "0"
272
+
273
+ if not isinstance(data, dict):
274
+ return "0"
275
+
276
+ app_ws = data.get("AppWorkshop")
277
+ if not isinstance(app_ws, dict):
278
+ return "0"
279
+
280
+ details = app_ws.get("WorkshopItemDetails")
281
+ if not isinstance(details, dict):
282
+ return "0"
283
+
284
+ entry = details.get(contentid)
285
+ if not isinstance(entry, dict):
286
+ return "0"
287
+
288
+ ltu = entry.get("latest_timeupdated")
289
+ if ltu is None:
290
+ return "0"
291
+
292
+ return str(ltu)
293
+
294
+
295
+ __all__ = [
296
+ "parse_appmanifest_acf",
297
+ "parse_appworkshop_acf",
298
+ "find_appmanifest_acf_files",
299
+ "find_appworkshop_acf_files",
300
+ "get_workshop_timeupdated",
301
+ "get_workshop_latest_timeupdated",
302
+ ]
modmgr/apply_ops.py ADDED
@@ -0,0 +1,145 @@
1
+ """apply_ops.py — Pure apply primitive.
2
+
3
+ Executes file-to-file apply operations according to a pre-computed plan.
4
+ Does NOT derive backup_dirs, does NOT handle directories, does NOT make decisions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ from typing import Any, Callable
12
+
13
+ # ── Public API ──────────────────────────────────────────────────────────
14
+
15
+
16
+ def apply_entries(
17
+ entries_by_backup_dir: dict[str, list[dict[str, Any]]],
18
+ *,
19
+ dry_run: bool = False,
20
+ on_progress: Callable[..., None] | None = None,
21
+ ) -> dict[str, Any]:
22
+ """Execute file-to-file apply.
23
+
24
+ Args:
25
+ entries_by_backup_dir: {backup_dir_path: [{path, request}, ...]}
26
+ dry_run: If True, simulate without modifying files.
27
+ on_progress: Optional progress callback.
28
+
29
+ Returns:
30
+ dict with keys: ok, applied, skipped, errors, warnings, diagnostics, dry_run
31
+ """
32
+ applied: list[dict[str, Any]] = []
33
+ skipped: list[dict[str, Any]] = []
34
+ errors: list[str] = []
35
+ warnings: list[str] = []
36
+ diagnostics: dict[str, Any] = {}
37
+
38
+ total = sum(len(v) for v in entries_by_backup_dir.values())
39
+ finished = 0
40
+
41
+ for backup_dir, entries in entries_by_backup_dir.items():
42
+ _notify(on_progress, "apply", finished, total, f"Applying in {backup_dir}")
43
+
44
+ for entry in entries:
45
+ target_path = entry["path"]
46
+ request = entry.get("request")
47
+
48
+ if request is None:
49
+ skipped.append({"path": target_path, "reason": "no source (vacuous node)"})
50
+ finished += 1
51
+ continue
52
+
53
+ action = request["action"]
54
+ source_path = request["path"]
55
+
56
+ # ── File-to-file guard ─────────────────────────────────
57
+ if not _is_file_path(target_path):
58
+ errors.append(f"E_APPLY_NOT_FILE: target path is not a file: {target_path}")
59
+ finished += 1
60
+ continue
61
+
62
+ try:
63
+ if action == "delete":
64
+ if not dry_run:
65
+ _assert_is_file(target_path, action)
66
+ os.remove(target_path)
67
+ applied.append({
68
+ "action": "delete",
69
+ "target": target_path,
70
+ "size": 0,
71
+ "mtime": 0,
72
+ "is_dir": False,
73
+ })
74
+
75
+ elif action in ("replace", "create"):
76
+ if not source_path or source_path == "!":
77
+ errors.append(f"E_APPLY_MISSING_SOURCE: no source for {action} on {target_path}")
78
+ finished += 1
79
+ continue
80
+
81
+ if not dry_run:
82
+ _assert_is_file(source_path, action)
83
+ os.makedirs(os.path.dirname(target_path), exist_ok=True)
84
+ shutil.copy2(source_path, target_path)
85
+
86
+ st = os.stat(source_path) if os.path.exists(source_path) else None
87
+ applied.append({
88
+ "action": action,
89
+ "source": source_path,
90
+ "target": target_path,
91
+ "size": st.st_size if st else 0,
92
+ "mtime": st.st_mtime if st else 0,
93
+ "is_dir": False,
94
+ })
95
+
96
+ else:
97
+ skipped.append({"path": target_path, "action": action, "reason": "unknown action"})
98
+
99
+ except FileNotFoundError as exc:
100
+ errors.append(f"E_APPLY_MISSING_SOURCE: {source_path}: {exc}")
101
+ except PermissionError as exc:
102
+ errors.append(f"E_APPLY_PERMISSION: {target_path}: {exc}")
103
+ except OSError as exc:
104
+ errors.append(f"E_APPLY_FAILED: {target_path}: {exc}")
105
+
106
+ finished += 1
107
+ _notify(on_progress, "apply", finished, total)
108
+
109
+ if not applied and not errors:
110
+ warnings.append("W_APPLY_NO_EFFECT: no entries were applied")
111
+
112
+ return {
113
+ "ok": len(errors) == 0,
114
+ "applied": applied,
115
+ "skipped": skipped,
116
+ "errors": errors,
117
+ "warnings": warnings,
118
+ "diagnostics": diagnostics,
119
+ "dry_run": dry_run,
120
+ }
121
+
122
+
123
+ # ── Internal helpers ────────────────────────────────────────────────────
124
+
125
+
126
+ def _is_file_path(path: str) -> bool:
127
+ """True if path does NOT end with '/' (file semantics)."""
128
+ return not path.endswith("/")
129
+
130
+
131
+ def _assert_is_file(path: str, action: str) -> None:
132
+ """Raise if path is a directory (apply is file-to-file only)."""
133
+ if os.path.isdir(path):
134
+ raise IsADirectoryError(f"E_APPLY_DIRECTORY_NOT_ALLOWED: {action} target is a directory: {path}")
135
+
136
+
137
+ def _notify(
138
+ on_progress: Callable[..., None] | None,
139
+ step: str,
140
+ finished: int,
141
+ total: int,
142
+ message: str = "",
143
+ ) -> None:
144
+ if on_progress:
145
+ on_progress(step, finished, total, message)
@@ -0,0 +1,218 @@
1
+ """backup_dir_builder.py — Automatic backup directory path derivation.
2
+
3
+ Provides functions to build a backup directory path from final_mapping,
4
+ database, and user_config, including workshop/custom backup ID generation
5
+ and .kmmignore rule loading.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .backup_ops import get_game_backup_id, get_workshop_timestamphex
15
+ from .paths import normalize_posix
16
+
17
+
18
+ def get_custom_backup_id(source_paths: list[str]) -> str:
19
+ """对自定义 mod(无 ACF),取所有源文件的最新 mtime 转为 hex。
20
+
21
+ 遍历 source_paths,取 max(mtime) → hex。
22
+ 若路径均为空或不存在 → 返回当前时间的 hex。
23
+
24
+ Returns:
25
+ mtime 的小写 hex 字符串
26
+ """
27
+ max_mtime: int = 0
28
+ now = int(time.time())
29
+
30
+ for sp in source_paths:
31
+ p = Path(sp)
32
+ if p.exists():
33
+ try:
34
+ mtime = int(p.stat().st_mtime)
35
+ if mtime > max_mtime:
36
+ max_mtime = mtime
37
+ except OSError:
38
+ continue
39
+
40
+ if max_mtime == 0:
41
+ return format(now, "x")
42
+
43
+ return format(max_mtime, "x")
44
+
45
+
46
+ def build_backup_dirs(
47
+ final_mapping: list[dict[str, Any]],
48
+ database: dict[str, Any],
49
+ user_config: dict[str, Any],
50
+ ) -> tuple[dict[str, list[str]], list[str]]:
51
+ """为 final_mapping 中的每个 app / contentid 构建各自的 backup_dir。
52
+
53
+ 每个 contentid 独立备份:在各自的根目录下创建 backup dir,
54
+ backup_id 从对应的 ACF 文件获取。
55
+
56
+ Args:
57
+ final_mapping: mapping 条目列表,每项含 "path" 键
58
+ database: 含 "game" 列表
59
+ user_config: 含 "baksuffix" 等
60
+
61
+ Returns:
62
+ ({backup_dir: [file_paths]}, warnings)
63
+ - backup_dir → 属于该目录的文件绝对路径列表
64
+ - warnings: 稳定性检查中被跳过的 contentid 警告信息
65
+ """
66
+ baksuffix = str(user_config.get("baksuffix", "kmmbackup"))
67
+
68
+ # ── 1. 收集所有目标路径 ──────────────────────────────────────────
69
+ targets: list[str] = []
70
+ for entry in final_mapping:
71
+ p = entry.get("path")
72
+ if p:
73
+ targets.append(normalize_posix(str(p)))
74
+
75
+ if not targets:
76
+ raise ValueError("E_BACKUP_DIR_BUILD_NO_APPID: final_mapping has no paths")
77
+
78
+ # ── 2. 构建 game_entries 索引 ─────────────────────────────────────
79
+ games = database.get("game", [])
80
+ game_entries: list[dict[str, Any]] = []
81
+ for g in games:
82
+ if not isinstance(g, dict):
83
+ continue
84
+ appid = str(g.get("appid", ""))
85
+ if not appid:
86
+ continue
87
+ basepath = str(g.get("basepath", ""))
88
+ modpath = str(g.get("modpath", ""))
89
+ if basepath:
90
+ basepath = normalize_posix(basepath)
91
+ if modpath:
92
+ modpath = normalize_posix(modpath)
93
+ game_entries.append({
94
+ "appid": appid,
95
+ "basepath": basepath,
96
+ "modpath": modpath,
97
+ })
98
+
99
+ # ── 3. 对每个 target,找到匹配的 game entry,分类 ──────────────────
100
+ # app_hits: {appid: {basepath, files: [paths], steamapps_path}}
101
+ # content_hits: {(appid, contentid): {modpath, contentid, files: [paths], steamapps_path}}
102
+ from collections import defaultdict
103
+
104
+ app_hits: dict[str, dict] = {}
105
+ content_hits: dict[tuple[str, str], dict] = {}
106
+ warnings: list[str] = []
107
+
108
+ for target in targets:
109
+ matched = False
110
+ for ge in game_entries:
111
+ # Check basepath (game app, modid==0)
112
+ if ge["basepath"] and target.startswith(ge["basepath"]):
113
+ appid = ge["appid"]
114
+ if appid not in app_hits:
115
+ steamapps = normalize_posix(str(Path(ge["basepath"]).parent.parent))
116
+ app_hits[appid] = {
117
+ "basepath": ge["basepath"],
118
+ "files": [],
119
+ "steamapps_path": steamapps,
120
+ }
121
+ app_hits[appid]["files"].append(target)
122
+ matched = True
123
+ break
124
+
125
+ # Check modpath (workshop contentid)
126
+ if ge["modpath"] and target.startswith(ge["modpath"]):
127
+ # Extract contentid from path: .../content/{appid}/{contentid}/...
128
+ appid = ge["appid"]
129
+ rel = target[len(ge["modpath"]):].lstrip("/")
130
+ parts = rel.split("/")
131
+ contentid = parts[0] if parts else ""
132
+ if contentid:
133
+ key = (appid, contentid)
134
+ if key not in content_hits:
135
+ steamapps = normalize_posix(str(Path(ge["modpath"]).parent.parent.parent))
136
+ content_hits[key] = {
137
+ "modpath": ge["modpath"],
138
+ "contentid": contentid,
139
+ "files": [],
140
+ "steamapps_path": steamapps,
141
+ }
142
+ content_hits[key]["files"].append(target)
143
+ matched = True
144
+ break
145
+
146
+ if not matched:
147
+ warnings.append(f"W_BACKUP_DIR_BUILD_NO_MATCH: {target}")
148
+
149
+ # ── 4. 构建 backup_dirs ───────────────────────────────────────────
150
+ result: dict[str, list[str]] = {}
151
+
152
+ # App backups
153
+ for appid, info in app_hits.items():
154
+ ok, hex_id, err = get_game_backup_id(info["steamapps_path"], appid)
155
+ if not ok:
156
+ warnings.append(err)
157
+ continue
158
+ backup_dir = normalize_posix(
159
+ f"{info['basepath']}/{appid}.{hex_id}.{baksuffix}/"
160
+ )
161
+ result[backup_dir] = info["files"]
162
+
163
+ # Content backups
164
+ for (appid, contentid), info in content_hits.items():
165
+ ok, hex_id, err = get_workshop_timestamphex(
166
+ info["steamapps_path"], appid, contentid
167
+ )
168
+ if not ok:
169
+ warnings.append(err)
170
+ continue
171
+ backup_dir = normalize_posix(
172
+ f"{info['modpath']}/{contentid}/{contentid}.{hex_id}.{baksuffix}/"
173
+ )
174
+ result[backup_dir] = info["files"]
175
+
176
+ if not result:
177
+ raise ValueError("E_BACKUP_DIR_BUILD_NO_APPID: no stable app/contentid found for backup")
178
+
179
+ return result, warnings
180
+
181
+
182
+ # Keep old function as compatibility wrapper
183
+ def build_backup_dir(
184
+ final_mapping: list[dict[str, Any]],
185
+ database: dict[str, Any],
186
+ user_config: dict[str, Any],
187
+ ) -> str:
188
+ """兼容旧接口:调用 build_backup_dirs 并返回第一个 backup_dir。"""
189
+ dirs, _warnings = build_backup_dirs(final_mapping, database, user_config)
190
+ return next(iter(dirs.keys()))
191
+
192
+
193
+ def load_dir_suffixes(user_config: dict[str, Any]) -> list[str]:
194
+ """合并硬编码底线 + user_config.ignore,去重返回目录名后缀列表。
195
+
196
+ 硬编码底线 ``".kmmbackup"`` 始终在列表中。
197
+ user_config.ignore 中的条目自动补前导 ``.``(若缺失)。
198
+ """
199
+ suffixes = [".kmmbackup"]
200
+ config_ignore = user_config.get("ignore")
201
+ if isinstance(config_ignore, list):
202
+ for item in config_ignore:
203
+ if isinstance(item, str) and item.strip():
204
+ s = item.strip()
205
+ if not s.startswith("."):
206
+ s = "." + s
207
+ if s not in suffixes:
208
+ suffixes.append(s)
209
+ return suffixes
210
+
211
+
212
+ __all__ = [
213
+ "get_custom_backup_id",
214
+ "get_workshop_timestamphex",
215
+ "build_backup_dir",
216
+ "build_backup_dirs",
217
+ "load_dir_suffixes",
218
+ ]