modmgr 0.0.1__tar.gz

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 (85) hide show
  1. modmgr-0.0.1/LICENSE +7 -0
  2. modmgr-0.0.1/PKG-INFO +95 -0
  3. modmgr-0.0.1/README.md +76 -0
  4. modmgr-0.0.1/pyproject.toml +42 -0
  5. modmgr-0.0.1/setup.cfg +4 -0
  6. modmgr-0.0.1/src/modmgr/__init__.py +16 -0
  7. modmgr-0.0.1/src/modmgr/acf_parser.py +302 -0
  8. modmgr-0.0.1/src/modmgr/apply_ops.py +145 -0
  9. modmgr-0.0.1/src/modmgr/backup_dir_builder.py +218 -0
  10. modmgr-0.0.1/src/modmgr/backup_ops.py +657 -0
  11. modmgr-0.0.1/src/modmgr/bootstrap.py +264 -0
  12. modmgr-0.0.1/src/modmgr/cli.py +509 -0
  13. modmgr-0.0.1/src/modmgr/core/__init__.py +1 -0
  14. modmgr-0.0.1/src/modmgr/core/workspacemanager.py +219 -0
  15. modmgr-0.0.1/src/modmgr/database_ops.py +621 -0
  16. modmgr-0.0.1/src/modmgr/engine.py +740 -0
  17. modmgr-0.0.1/src/modmgr/forest_visual.py +983 -0
  18. modmgr-0.0.1/src/modmgr/iojson.py +26 -0
  19. modmgr-0.0.1/src/modmgr/orchestrator/__init__.py +308 -0
  20. modmgr-0.0.1/src/modmgr/orchestrator/_common.py +95 -0
  21. modmgr-0.0.1/src/modmgr/orchestrator/compute_pipeline.py +232 -0
  22. modmgr-0.0.1/src/modmgr/orchestrator/entry.py +38 -0
  23. modmgr-0.0.1/src/modmgr/orchestrator/ignore_rules.py +141 -0
  24. modmgr-0.0.1/src/modmgr/orchestrator/planner_fileops.py +151 -0
  25. modmgr-0.0.1/src/modmgr/orchestrator/preflight.py +93 -0
  26. modmgr-0.0.1/src/modmgr/orchestrator/resolver.py +102 -0
  27. modmgr-0.0.1/src/modmgr/path_resolver.py +184 -0
  28. modmgr-0.0.1/src/modmgr/paths.py +85 -0
  29. modmgr-0.0.1/src/modmgr/pathstyle.py +111 -0
  30. modmgr-0.0.1/src/modmgr/prep.py +115 -0
  31. modmgr-0.0.1/src/modmgr/restore_ops.py +221 -0
  32. modmgr-0.0.1/src/modmgr/rule_aggregator.py +591 -0
  33. modmgr-0.0.1/src/modmgr/schema.py +49 -0
  34. modmgr-0.0.1/src/modmgr/steam_scanner.py +395 -0
  35. modmgr-0.0.1/src/modmgr/validation.py +216 -0
  36. modmgr-0.0.1/src/modmgr/vdf_parser.py +113 -0
  37. modmgr-0.0.1/src/modmgr.egg-info/PKG-INFO +95 -0
  38. modmgr-0.0.1/src/modmgr.egg-info/SOURCES.txt +83 -0
  39. modmgr-0.0.1/src/modmgr.egg-info/dependency_links.txt +1 -0
  40. modmgr-0.0.1/src/modmgr.egg-info/entry_points.txt +2 -0
  41. modmgr-0.0.1/src/modmgr.egg-info/requires.txt +7 -0
  42. modmgr-0.0.1/src/modmgr.egg-info/top_level.txt +2 -0
  43. modmgr-0.0.1/src/modmgr_web/__init__.py +10 -0
  44. modmgr-0.0.1/src/modmgr_web/__main__.py +29 -0
  45. modmgr-0.0.1/src/modmgr_web/adapters.py +105 -0
  46. modmgr-0.0.1/src/modmgr_web/app.py +79 -0
  47. modmgr-0.0.1/src/modmgr_web/routes/__init__.py +1 -0
  48. modmgr-0.0.1/src/modmgr_web/routes/backups.py +147 -0
  49. modmgr-0.0.1/src/modmgr_web/routes/config.py +73 -0
  50. modmgr-0.0.1/src/modmgr_web/routes/database.py +99 -0
  51. modmgr-0.0.1/src/modmgr_web/routes/pipeline.py +158 -0
  52. modmgr-0.0.1/src/modmgr_web/routes/rules.py +369 -0
  53. modmgr-0.0.1/src/modmgr_web/routes/workspace.py +379 -0
  54. modmgr-0.0.1/src/modmgr_web/schemas.py +184 -0
  55. modmgr-0.0.1/src/modmgr_web/sse.py +90 -0
  56. modmgr-0.0.1/tests/test_apply_ops.py +245 -0
  57. modmgr-0.0.1/tests/test_backup_dir_builder.py +336 -0
  58. modmgr-0.0.1/tests/test_backup_dir_multi_mod.py +168 -0
  59. modmgr-0.0.1/tests/test_backup_ops.py +716 -0
  60. modmgr-0.0.1/tests/test_backup_tree.py +157 -0
  61. modmgr-0.0.1/tests/test_bootstrap.py +198 -0
  62. modmgr-0.0.1/tests/test_cli_database_ops.py +442 -0
  63. modmgr-0.0.1/tests/test_contract.py +379 -0
  64. modmgr-0.0.1/tests/test_database_ops.py +296 -0
  65. modmgr-0.0.1/tests/test_dispatch_cross_contam.py +153 -0
  66. modmgr-0.0.1/tests/test_dump_backup_assignment.py +168 -0
  67. modmgr-0.0.1/tests/test_engine.py +1086 -0
  68. modmgr-0.0.1/tests/test_forest_visual.py +445 -0
  69. modmgr-0.0.1/tests/test_full_pipeline_integration.py +305 -0
  70. modmgr-0.0.1/tests/test_ignore_rules.py +97 -0
  71. modmgr-0.0.1/tests/test_integration_fixtures.py +786 -0
  72. modmgr-0.0.1/tests/test_iojson.py +30 -0
  73. modmgr-0.0.1/tests/test_orchestrator.py +628 -0
  74. modmgr-0.0.1/tests/test_orchestrator_engine.py +199 -0
  75. modmgr-0.0.1/tests/test_path_resolver.py +187 -0
  76. modmgr-0.0.1/tests/test_paths.py +56 -0
  77. modmgr-0.0.1/tests/test_pathstyle.py +71 -0
  78. modmgr-0.0.1/tests/test_preflight.py +182 -0
  79. modmgr-0.0.1/tests/test_restore_ops.py +227 -0
  80. modmgr-0.0.1/tests/test_restore_per_design.py +210 -0
  81. modmgr-0.0.1/tests/test_rule_aggregator.py +808 -0
  82. modmgr-0.0.1/tests/test_steam_scanner.py +501 -0
  83. modmgr-0.0.1/tests/test_validation.py +257 -0
  84. modmgr-0.0.1/tests/test_web_api.py +1350 -0
  85. modmgr-0.0.1/tests/test_web_entrypoint.py +25 -0
modmgr-0.0.1/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2025-2026 Knighthana
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
modmgr-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: modmgr
3
+ Version: 0.0.1
4
+ Summary: File mapping and backup rule engine for workshop mods
5
+ Project-URL: Homepage, https://github.com/Knighthana/modmanager
6
+ Project-URL: Repository, https://github.com/Knighthana/modmanager
7
+ Project-URL: Issues, https://github.com/Knighthana/modmanager/issues
8
+ Keywords: mod,manager,game,backup
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown; charset=UTF-8
11
+ License-File: LICENSE
12
+ Requires-Dist: vdf>=0.4.0
13
+ Requires-Dist: jsonschema>=3.2.0
14
+ Requires-Dist: gitignore-parser>=0.1.0
15
+ Provides-Extra: web
16
+ Requires-Dist: fastapi>=0.100; extra == "web"
17
+ Requires-Dist: uvicorn[standard]>=0.23; extra == "web"
18
+ Dynamic: license-file
19
+
20
+ ---
21
+ status: active
22
+ concept: readme
23
+ applicable-to: human
24
+ update: 2026-05-20
25
+ ---
26
+
27
+ The Knighthana's Mod Manager
28
+ ============================
29
+
30
+ This repository provides the full source tree, including the frontend and build tooling.
31
+ For PyPI, the current release path is intentionally narrower: install the core package first,
32
+ and use GitHub for the full web-enabled development experience.
33
+
34
+ # 简介
35
+
36
+ The Knighthana's Mod Manager(简称KMM)通过对"文件迁移过程"进行脱离具体环境的语义化定义和简单执行,实现对MOD->本体,MOD->MOD,还包括诸如MOD->MOD->MOD->本体这样嵌套方式的支持;
37
+
38
+ 并且针对具体的替换规则,实现原文件的备份保护和按需还原;
39
+
40
+ 作为“文件操作语义定义与管理”的雏形想法,这个项目初期在Steam游戏本体<->Steam创意工坊这样的环境上进行实践;
41
+
42
+ # 作为普通用户
43
+
44
+ 如果你希望获得完整的 Web 界面和前端源码,请直接使用本仓库的 GitHub 版本。
45
+ 如果你只是想先通过 PyPI 安装核心能力,目前发布物会优先保证 CLI / 后端逻辑可用。
46
+
47
+ 如果要作为普通用户使用这个项目,请前往[release]()页面,下载符合自己系统平台的分发文件,遵守指引安装;
48
+
49
+ 待程序运行之后,请打开浏览器在[KMM面板](127.0.0.1:8000)进行操作:
50
+
51
+ 1. 在“数据来源”点击“扫描Steam库”等待其完成,这步是为了创建游戏-MOD数据库;
52
+ 2. 在“设置面板”的“规则来源”下方,点击“➕ 添加来源”创建一个新的空行,将Modder交给你的kmmrule文件的路径填写在空行中,点击“保存”;
53
+ 3. **务必**点击下方的“保存设置”按钮,对设定进行保存;
54
+ 4. 进入“工作区”页面,点击右上方的“新建工作区”,输入必要的信息来创建一个工作区,每个工作区各自保存了一种替换模式,尽可能为每类不同的任务创建不同的工作区;
55
+ 5. 在面板中点击工作区条目的“进入”来进入一个工作区;
56
+ 6. 在“发现的规则文件”勾选Modder提供给你的kmmrule文件前方的复选框,如果感兴趣可以点击右侧的“展开”以查看这个规则的详细信息;
57
+ 7. 点击“进入计算准备”,系统会自动将你导航到“计算准备”页面;
58
+ 8. 仅保留想要替换的库/游戏/MOD前方的复选框,之后点击“开始计算”或者“计算查看”;复选框用于确定实际影响的游戏/MOD,可见按钮仅仅用于调整视觉效果,不影响最终结果;
59
+ 9. 自行点击或等待系统导航进入“森林可视”界面,可以放大映射图,检查这些映射是不是你所想要的;
60
+ 10. 如果有必要,在“冲突裁决”页面进行手动裁决;
61
+ 11. 进入“文件操作”页面,如果这是首次操作,必须先点击“备份”,如果心存疑虑,务必打开"dry run"开关进行模拟;"dry run"不会进行任何实际的文件操作;
62
+ 12. “备份”完成后,可以点击“应用”进行文件替换,等待操作完成;
63
+ 13. 如果您的游戏/MOD需要分时间使用多个不同的规则,那么下次请在“备份”前先点击“恢复”,然后再“备份”和“应用”;
64
+
65
+ ## 注意事项
66
+
67
+ 本项目采用“懒备份”和“蠢还原”两个原则进行文件管理,它不会也不应该推测当前目录中文件的来源,备份时永远假定当前状态是需要被记录的快照,还原时只对比外部文件与备份文件之间的相似度,因此:
68
+
69
+ - 仅在文件目录处于“被Steam验证后”的“原始状态”时,才使用“备份”功能;
70
+ - 若当前文件已经是“应用”之后的样子,务必在下一次切换规则前,**使用“恢复”功能**使目录恢复原状,才能开始下一次“备份”和“替换”作业;
71
+
72
+ # 作为Modder
73
+
74
+ 如果想要快速上手,请参考`description/kmm_rule_RWR-khn_CT-castears-z2414_Replace.json.example`撰写自己的kmmrule规则文件;
75
+
76
+ 如果有一定的软件开发基础,或者对产出有更高的要求,更加建议参考`repo_spec/`中的schema文件,或者具体的代码实现,撰写更加安全和灵活的kmmrule规则文件;
77
+
78
+ 在分发Mod时,建议同时分发kmmrule文件和本项目的链接,指引用户通过kmmrule进行规则化的MOD安装;
79
+
80
+ # 作为开发者
81
+
82
+ 欢迎为本项目贡献代码;
83
+
84
+ 目前项目已经确定的未来方向是:
85
+
86
+ - 迁移到[Tauri2](https://v2.tauri.app/zh-cn/),提供更加定制化的GUI方便使用;
87
+ - 完善“规则制定”页面,方便kmmrule文件的制定;
88
+ - 进行内部资源传递优化方案,初步便于readme和preview的展示,之后还可灵活应用于其它外部资源;
89
+ - 将项目脱离SteamWorks场景使用,用于其他游戏MOD文件的管理;
90
+ - 实现对任意类型目录任意文件的管理,或是应用于嵌入式设备中进行资源敏感场合的文件管理;
91
+ - 脱离“文件系统”场景,允许后端接驳Redis等广泛意义上的“资源provider”;
92
+
93
+ 欢迎提出更多的想法,比如“我想用它管理Minecraft模组”,或者“我想用它管理我的本地音乐收藏”,或者“我有一个嵌入式设备中需要这样的管理器对规则集合进行管理”;
94
+
95
+ 项目还在成长,接受各种想法;
modmgr-0.0.1/README.md ADDED
@@ -0,0 +1,76 @@
1
+ ---
2
+ status: active
3
+ concept: readme
4
+ applicable-to: human
5
+ update: 2026-05-20
6
+ ---
7
+
8
+ The Knighthana's Mod Manager
9
+ ============================
10
+
11
+ This repository provides the full source tree, including the frontend and build tooling.
12
+ For PyPI, the current release path is intentionally narrower: install the core package first,
13
+ and use GitHub for the full web-enabled development experience.
14
+
15
+ # 简介
16
+
17
+ The Knighthana's Mod Manager(简称KMM)通过对"文件迁移过程"进行脱离具体环境的语义化定义和简单执行,实现对MOD->本体,MOD->MOD,还包括诸如MOD->MOD->MOD->本体这样嵌套方式的支持;
18
+
19
+ 并且针对具体的替换规则,实现原文件的备份保护和按需还原;
20
+
21
+ 作为“文件操作语义定义与管理”的雏形想法,这个项目初期在Steam游戏本体<->Steam创意工坊这样的环境上进行实践;
22
+
23
+ # 作为普通用户
24
+
25
+ 如果你希望获得完整的 Web 界面和前端源码,请直接使用本仓库的 GitHub 版本。
26
+ 如果你只是想先通过 PyPI 安装核心能力,目前发布物会优先保证 CLI / 后端逻辑可用。
27
+
28
+ 如果要作为普通用户使用这个项目,请前往[release]()页面,下载符合自己系统平台的分发文件,遵守指引安装;
29
+
30
+ 待程序运行之后,请打开浏览器在[KMM面板](127.0.0.1:8000)进行操作:
31
+
32
+ 1. 在“数据来源”点击“扫描Steam库”等待其完成,这步是为了创建游戏-MOD数据库;
33
+ 2. 在“设置面板”的“规则来源”下方,点击“➕ 添加来源”创建一个新的空行,将Modder交给你的kmmrule文件的路径填写在空行中,点击“保存”;
34
+ 3. **务必**点击下方的“保存设置”按钮,对设定进行保存;
35
+ 4. 进入“工作区”页面,点击右上方的“新建工作区”,输入必要的信息来创建一个工作区,每个工作区各自保存了一种替换模式,尽可能为每类不同的任务创建不同的工作区;
36
+ 5. 在面板中点击工作区条目的“进入”来进入一个工作区;
37
+ 6. 在“发现的规则文件”勾选Modder提供给你的kmmrule文件前方的复选框,如果感兴趣可以点击右侧的“展开”以查看这个规则的详细信息;
38
+ 7. 点击“进入计算准备”,系统会自动将你导航到“计算准备”页面;
39
+ 8. 仅保留想要替换的库/游戏/MOD前方的复选框,之后点击“开始计算”或者“计算查看”;复选框用于确定实际影响的游戏/MOD,可见按钮仅仅用于调整视觉效果,不影响最终结果;
40
+ 9. 自行点击或等待系统导航进入“森林可视”界面,可以放大映射图,检查这些映射是不是你所想要的;
41
+ 10. 如果有必要,在“冲突裁决”页面进行手动裁决;
42
+ 11. 进入“文件操作”页面,如果这是首次操作,必须先点击“备份”,如果心存疑虑,务必打开"dry run"开关进行模拟;"dry run"不会进行任何实际的文件操作;
43
+ 12. “备份”完成后,可以点击“应用”进行文件替换,等待操作完成;
44
+ 13. 如果您的游戏/MOD需要分时间使用多个不同的规则,那么下次请在“备份”前先点击“恢复”,然后再“备份”和“应用”;
45
+
46
+ ## 注意事项
47
+
48
+ 本项目采用“懒备份”和“蠢还原”两个原则进行文件管理,它不会也不应该推测当前目录中文件的来源,备份时永远假定当前状态是需要被记录的快照,还原时只对比外部文件与备份文件之间的相似度,因此:
49
+
50
+ - 仅在文件目录处于“被Steam验证后”的“原始状态”时,才使用“备份”功能;
51
+ - 若当前文件已经是“应用”之后的样子,务必在下一次切换规则前,**使用“恢复”功能**使目录恢复原状,才能开始下一次“备份”和“替换”作业;
52
+
53
+ # 作为Modder
54
+
55
+ 如果想要快速上手,请参考`description/kmm_rule_RWR-khn_CT-castears-z2414_Replace.json.example`撰写自己的kmmrule规则文件;
56
+
57
+ 如果有一定的软件开发基础,或者对产出有更高的要求,更加建议参考`repo_spec/`中的schema文件,或者具体的代码实现,撰写更加安全和灵活的kmmrule规则文件;
58
+
59
+ 在分发Mod时,建议同时分发kmmrule文件和本项目的链接,指引用户通过kmmrule进行规则化的MOD安装;
60
+
61
+ # 作为开发者
62
+
63
+ 欢迎为本项目贡献代码;
64
+
65
+ 目前项目已经确定的未来方向是:
66
+
67
+ - 迁移到[Tauri2](https://v2.tauri.app/zh-cn/),提供更加定制化的GUI方便使用;
68
+ - 完善“规则制定”页面,方便kmmrule文件的制定;
69
+ - 进行内部资源传递优化方案,初步便于readme和preview的展示,之后还可灵活应用于其它外部资源;
70
+ - 将项目脱离SteamWorks场景使用,用于其他游戏MOD文件的管理;
71
+ - 实现对任意类型目录任意文件的管理,或是应用于嵌入式设备中进行资源敏感场合的文件管理;
72
+ - 脱离“文件系统”场景,允许后端接驳Redis等广泛意义上的“资源provider”;
73
+
74
+ 欢迎提出更多的想法,比如“我想用它管理Minecraft模组”,或者“我想用它管理我的本地音乐收藏”,或者“我有一个嵌入式设备中需要这样的管理器对规则集合进行管理”;
75
+
76
+ 项目还在成长,接受各种想法;
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "modmgr"
7
+ version = "0.0.1"
8
+ description = "File mapping and backup rule engine for workshop mods"
9
+ requires-python = ">=3.10"
10
+ keywords = ["mod", "manager", "game", "backup"]
11
+ dependencies = [
12
+ "vdf>=0.4.0",
13
+ "jsonschema>=3.2.0",
14
+ "gitignore-parser>=0.1.0",
15
+ ]
16
+
17
+ [project.readme]
18
+ file = "README.md"
19
+ content-type = "text/markdown; charset=UTF-8"
20
+
21
+ [project.scripts]
22
+ modmgr-cli = "hana_modmgr.cli:main"
23
+
24
+ [project.optional-dependencies]
25
+ web = [
26
+ "fastapi>=0.100",
27
+ "uvicorn[standard]>=0.23",
28
+ ]
29
+
30
+ [tool.setuptools]
31
+ package-dir = {"" = "src"}
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/Knighthana/modmanager"
38
+ Repository = "https://github.com/Knighthana/modmanager"
39
+ Issues = "https://github.com/Knighthana/modmanager/issues"
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
modmgr-0.0.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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
+ ]