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.
- modmgr/__init__.py +16 -0
- modmgr/acf_parser.py +302 -0
- modmgr/apply_ops.py +145 -0
- modmgr/backup_dir_builder.py +218 -0
- modmgr/backup_ops.py +657 -0
- modmgr/bootstrap.py +264 -0
- modmgr/cli.py +509 -0
- modmgr/core/__init__.py +1 -0
- modmgr/core/workspacemanager.py +219 -0
- modmgr/database_ops.py +621 -0
- modmgr/engine.py +740 -0
- modmgr/forest_visual.py +983 -0
- modmgr/iojson.py +26 -0
- modmgr/orchestrator/__init__.py +308 -0
- modmgr/orchestrator/_common.py +95 -0
- modmgr/orchestrator/compute_pipeline.py +232 -0
- modmgr/orchestrator/entry.py +38 -0
- modmgr/orchestrator/ignore_rules.py +141 -0
- modmgr/orchestrator/planner_fileops.py +151 -0
- modmgr/orchestrator/preflight.py +93 -0
- modmgr/orchestrator/resolver.py +102 -0
- modmgr/path_resolver.py +184 -0
- modmgr/paths.py +85 -0
- modmgr/pathstyle.py +111 -0
- modmgr/prep.py +115 -0
- modmgr/restore_ops.py +221 -0
- modmgr/rule_aggregator.py +591 -0
- modmgr/schema.py +49 -0
- modmgr/steam_scanner.py +395 -0
- modmgr/validation.py +216 -0
- modmgr/vdf_parser.py +113 -0
- modmgr-0.0.1.dist-info/METADATA +95 -0
- modmgr-0.0.1.dist-info/RECORD +50 -0
- modmgr-0.0.1.dist-info/WHEEL +5 -0
- modmgr-0.0.1.dist-info/entry_points.txt +2 -0
- modmgr-0.0.1.dist-info/licenses/LICENSE +7 -0
- modmgr-0.0.1.dist-info/top_level.txt +2 -0
- modmgr_web/__init__.py +10 -0
- modmgr_web/__main__.py +29 -0
- modmgr_web/adapters.py +105 -0
- modmgr_web/app.py +79 -0
- modmgr_web/routes/__init__.py +1 -0
- modmgr_web/routes/backups.py +147 -0
- modmgr_web/routes/config.py +73 -0
- modmgr_web/routes/database.py +99 -0
- modmgr_web/routes/pipeline.py +158 -0
- modmgr_web/routes/rules.py +369 -0
- modmgr_web/routes/workspace.py +379 -0
- modmgr_web/schemas.py +184 -0
- modmgr_web/sse.py +90 -0
modmgr/__init__.py
ADDED
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
|
+
]
|