modelstage 0.0.0-stage → 0.5.24

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 (286) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -2
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/bin/modelstage.mjs +101 -0
  5. package/config/diag-upload.example.json +7 -0
  6. package/config/models/live2d.assetbundls.json +1 -0
  7. package/config/models/live2d.azurlane.json +1 -0
  8. package/config/models/live2d.azurlane_bundle.json +1 -0
  9. package/config/models/live2d.azurlane_spine.json +1 -0
  10. package/config/models/live2d.bd2.json +1 -0
  11. package/config/models/live2d.bluearchive.json +1 -0
  12. package/config/models/live2d.live2d.json +1 -0
  13. package/config/models/live2d.nikke.json +1 -0
  14. package/config/models/live2d.nikke_2.json +1 -0
  15. package/config/models/live2d.npc.json +1 -0
  16. package/config/models/spine.assetbundls.json +1 -0
  17. package/config/models/spine.azurlane.json +1 -0
  18. package/config/models/spine.azurlane_bundle.json +1 -0
  19. package/config/models/spine.azurlane_spine.json +1 -0
  20. package/config/models/spine.bd2.json +1 -0
  21. package/config/models/spine.bluearchive.json +1 -0
  22. package/config/models/spine.live2d.json +1 -0
  23. package/config/models/spine.nikke.json +1 -0
  24. package/config/models/spine.nikke_2.json +1 -0
  25. package/config/models/spine.npc.json +1 -0
  26. package/config/names/NIKKE.json +1058 -0
  27. package/config/names/README.md +47 -0
  28. package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
  29. package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
  30. package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
  31. package/config/plugins.json +13 -0
  32. package/config/resources.example.json +40 -0
  33. package/package.json +58 -4
  34. package/plugins/unity-live2d-bundle/README.md +98 -0
  35. package/plugins/unity-live2d-bundle/index.js +891 -0
  36. package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
  37. package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
  38. package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
  39. package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
  40. package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
  41. package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
  42. package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
  43. package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
  44. package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  45. package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
  46. package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
  47. package/plugins/unity-live2d-bundle/lib/png.js +424 -0
  48. package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
  49. package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
  50. package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
  51. package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
  52. package/plugins/unity-live2d-bundle/plugin.json +81 -0
  53. package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
  54. package/plugins/unity-spine-bundle/index.js +535 -0
  55. package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
  56. package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
  57. package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
  58. package/plugins/unity-spine-bundle/lib/errors.js +79 -0
  59. package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
  60. package/plugins/unity-spine-bundle/lib/extract.js +242 -0
  61. package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  62. package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
  63. package/plugins/unity-spine-bundle/lib/png.js +424 -0
  64. package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
  65. package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
  66. package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
  67. package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
  68. package/plugins/unity-spine-bundle/plugin.json +75 -0
  69. package/src/server/README.md +69 -0
  70. package/src/server/assets/browse.mjs +56 -0
  71. package/src/server/assets/bundle-probe.mjs +94 -0
  72. package/src/server/assets/probe.mjs +184 -0
  73. package/src/server/core/config.mjs +173 -0
  74. package/src/server/core/env-num.mjs +60 -0
  75. package/src/server/core/env.mjs +49 -0
  76. package/src/server/core/fs-portable.mjs +37 -0
  77. package/src/server/core/log.mjs +107 -0
  78. package/src/server/core/paths.mjs +44 -0
  79. package/src/server/data/catalog.mjs +1091 -0
  80. package/src/server/data/entry-states.mjs +499 -0
  81. package/src/server/data/live2d.mjs +272 -0
  82. package/src/server/data/manifest-cache.mjs +58 -0
  83. package/src/server/data/manifest-stamp.mjs +135 -0
  84. package/src/server/data/model-meta.mjs +114 -0
  85. package/src/server/data/names.mjs +106 -0
  86. package/src/server/data/shape-probe-worker.mjs +33 -0
  87. package/src/server/data/shape-probe.mjs +626 -0
  88. package/src/server/data/spine.mjs +336 -0
  89. package/src/server/data/tree.mjs +126 -0
  90. package/src/server/http/atomic-json.mjs +145 -0
  91. package/src/server/http/background-solids.mjs +270 -0
  92. package/src/server/http/backgrounds.mjs +388 -0
  93. package/src/server/http/diag-upload.mjs +264 -0
  94. package/src/server/http/diag.mjs +173 -0
  95. package/src/server/http/ready.mjs +167 -0
  96. package/src/server/http/static.mjs +312 -0
  97. package/src/server/http/write.mjs +647 -0
  98. package/src/server/index.mjs +114 -0
  99. package/src/server/plugins/builtin-providers.json +30 -0
  100. package/src/server/plugins/registry.mjs +650 -0
  101. package/src/server/plugins/slot-catalog.mjs +286 -0
  102. package/src/server/routes/background-solids.mjs +56 -0
  103. package/src/server/routes/backgrounds.mjs +77 -0
  104. package/src/server/routes/config.mjs +38 -0
  105. package/src/server/routes/data.mjs +313 -0
  106. package/src/server/routes/diag.mjs +37 -0
  107. package/src/server/routes/entries.mjs +170 -0
  108. package/src/server/routes/ready.mjs +122 -0
  109. package/src/server/serve.mjs +334 -0
  110. package/src/server/unpack/capacity.mjs +217 -0
  111. package/src/server/unpack/mem-probe.mjs +27 -0
  112. package/src/server/unpack/plugin-runner.mjs +529 -0
  113. package/src/server/unpack/plugin-store.mjs +736 -0
  114. package/src/server/unpack/probe-store.mjs +73 -0
  115. package/src/server/unpack/routes.mjs +359 -0
  116. package/src/server/unpack/runtime/exec.mjs +558 -0
  117. package/src/server/unpack/runtime/index.mjs +141 -0
  118. package/src/server/unpack/runtime/node-vm.mjs +111 -0
  119. package/src/server/unpack/unpack-worker.mjs +105 -0
  120. package/src/server/unpack/worker-client.mjs +184 -0
  121. package/src/server/unpack/write-result.mjs +84 -0
  122. package/src/shared/bundle-shape.mjs +83 -0
  123. package/src/shared/entry-reasons.mjs +119 -0
  124. package/src/shared/names.mjs +45 -0
  125. package/src/web/README.md +193 -0
  126. package/src/web/app/anim-list.js +222 -0
  127. package/src/web/app/background-solids.js +150 -0
  128. package/src/web/app/backgrounds.js +152 -0
  129. package/src/web/app/camera-log.js +28 -0
  130. package/src/web/app/context.js +72 -0
  131. package/src/web/app/dir-picker.js +242 -0
  132. package/src/web/app/entry-report.js +71 -0
  133. package/src/web/app/memory-bars.js +91 -0
  134. package/src/web/app/memory-budget.js +228 -0
  135. package/src/web/app/memory-guard.js +204 -0
  136. package/src/web/app/model-actions.js +682 -0
  137. package/src/web/app/renderer-setting.js +70 -0
  138. package/src/web/app/root-manager.js +254 -0
  139. package/src/web/app/status.js +138 -0
  140. package/src/web/app/topbar-actions.js +357 -0
  141. package/src/web/app/transport-actions.js +297 -0
  142. package/src/web/app/ui-state.js +559 -0
  143. package/src/web/app/url-params.js +171 -0
  144. package/src/web/app/view-settings.js +186 -0
  145. package/src/web/components.css +465 -0
  146. package/src/web/debug/diag-hud.js +95 -0
  147. package/src/web/debug/drag.js +369 -0
  148. package/src/web/debug/log.js +320 -0
  149. package/src/web/debug/model-cmp.js +292 -0
  150. package/src/web/debug/panel.css +474 -0
  151. package/src/web/debug/panel.js +712 -0
  152. package/src/web/debug/race-probe.js +82 -0
  153. package/src/web/debug/upload.js +130 -0
  154. package/src/web/engine/adapters/atlas.js +47 -0
  155. package/src/web/engine/adapters/index.js +22 -0
  156. package/src/web/engine/adapters/renderer.js +223 -0
  157. package/src/web/engine/adapters/skeleton.js +105 -0
  158. package/src/web/engine/adapters/texture.js +64 -0
  159. package/src/web/engine/animation.js +181 -0
  160. package/src/web/engine/appearance.js +136 -0
  161. package/src/web/engine/camera-adapter.js +212 -0
  162. package/src/web/engine/effect-chain.js +138 -0
  163. package/src/web/engine/engine-state.js +120 -0
  164. package/src/web/engine/filters.js +224 -0
  165. package/src/web/engine/fit.js +197 -0
  166. package/src/web/engine/frame-gate.js +33 -0
  167. package/src/web/engine/gl-driver.js +56 -0
  168. package/src/web/engine/gl.js +77 -0
  169. package/src/web/engine/index.js +20 -0
  170. package/src/web/engine/loader.js +55 -0
  171. package/src/web/engine/model-info.js +26 -0
  172. package/src/web/engine/pipeline.js +200 -0
  173. package/src/web/engine/player.js +427 -0
  174. package/src/web/engine/pma/blend.js +76 -0
  175. package/src/web/engine/pma/detect.js +118 -0
  176. package/src/web/engine/pma/index.js +8 -0
  177. package/src/web/engine/pma/resolve.js +112 -0
  178. package/src/web/engine/pma/upload.js +127 -0
  179. package/src/web/engine/pma.js +5 -0
  180. package/src/web/engine/render-frame.js +93 -0
  181. package/src/web/engine/runtime-capabilities.js +209 -0
  182. package/src/web/engine/runtime.js +37 -0
  183. package/src/web/engine/skins.js +165 -0
  184. package/src/web/engine/textures.js +174 -0
  185. package/src/web/index.html +115 -0
  186. package/src/web/infra/api.js +531 -0
  187. package/src/web/infra/diag.js +509 -0
  188. package/src/web/infra/document.js +269 -0
  189. package/src/web/infra/dom.js +31 -0
  190. package/src/web/infra/events.mjs +27 -0
  191. package/src/web/infra/log.js +153 -0
  192. package/src/web/infra/mem-stats.js +112 -0
  193. package/src/web/infra/plugin-host-registration.mjs +285 -0
  194. package/src/web/infra/plugin-host-report.mjs +32 -0
  195. package/src/web/infra/plugin-host-unload.mjs +79 -0
  196. package/src/web/infra/plugin-host-unpack.mjs +899 -0
  197. package/src/web/infra/plugin-host-warmup.mjs +180 -0
  198. package/src/web/infra/plugin-host.js +628 -0
  199. package/src/web/infra/ready.js +461 -0
  200. package/src/web/infra/schedule.js +51 -0
  201. package/src/web/infra/shortcuts.js +129 -0
  202. package/src/web/infra/slot-registry.js +402 -0
  203. package/src/web/infra/vfs.js +247 -0
  204. package/src/web/live2d/bridge.js +400 -0
  205. package/src/web/live2d/model.js +321 -0
  206. package/src/web/live2d/overlay.js +142 -0
  207. package/src/web/live2d/pixi-app.js +111 -0
  208. package/src/web/live2d/playback-probe.js +92 -0
  209. package/src/web/live2d/probe-geometry.js +329 -0
  210. package/src/web/live2d/probe.js +119 -0
  211. package/src/web/live2d/release.js +66 -0
  212. package/src/web/live2d/runtime-adapter.js +289 -0
  213. package/src/web/live2d/stage-ctx.js +152 -0
  214. package/src/web/live2d/stage.js +204 -0
  215. package/src/web/live2d/vfs-settings.js +174 -0
  216. package/src/web/live2d/view.js +89 -0
  217. package/src/web/main.js +629 -0
  218. package/src/web/panel/background-list.js +373 -0
  219. package/src/web/panel/bg-source.js +291 -0
  220. package/src/web/panel/color-picker.css +199 -0
  221. package/src/web/panel/color-picker.js +278 -0
  222. package/src/web/panel/dialog.css +116 -0
  223. package/src/web/panel/dialog.js +173 -0
  224. package/src/web/panel/dom.js +35 -0
  225. package/src/web/panel/icons.js +58 -0
  226. package/src/web/panel/memory-bar.js +195 -0
  227. package/src/web/panel/panel-settings.js +1263 -0
  228. package/src/web/panel/panel.css +1304 -0
  229. package/src/web/panel/render-choice.js +104 -0
  230. package/src/web/panel/schema.js +784 -0
  231. package/src/web/panel/settings-io.js +291 -0
  232. package/src/web/panel/shell-controls.js +274 -0
  233. package/src/web/panel/slider.js +210 -0
  234. package/src/web/panel/toolbar-schema.js +111 -0
  235. package/src/web/panel/toolbar.js +195 -0
  236. package/src/web/panel/transport-picker.js +375 -0
  237. package/src/web/panel/transport.js +321 -0
  238. package/src/web/panel/view.js +553 -0
  239. package/src/web/plugin/unpack-worker.js +185 -0
  240. package/src/web/render/camera/constants.js +60 -0
  241. package/src/web/render/camera/index.js +123 -0
  242. package/src/web/render/camera/spine.js +124 -0
  243. package/src/web/render/capture.js +30 -0
  244. package/src/web/render/interaction.js +527 -0
  245. package/src/web/render/live2d.js +627 -0
  246. package/src/web/render/playback.js +47 -0
  247. package/src/web/render/reset-view.js +29 -0
  248. package/src/web/render/spine.js +386 -0
  249. package/src/web/render/surface-switch.js +176 -0
  250. package/src/web/render/surface.js +571 -0
  251. package/src/web/render/surfaces.js +70 -0
  252. package/src/web/roster/filter.js +31 -0
  253. package/src/web/roster/list.js +276 -0
  254. package/src/web/roster/persist.js +52 -0
  255. package/src/web/roster/reveal.js +77 -0
  256. package/src/web/roster/rows.js +248 -0
  257. package/src/web/roster/snapshot.js +37 -0
  258. package/src/web/roster/view.js +670 -0
  259. package/src/web/spine/canvas-api-groups.js +267 -0
  260. package/src/web/spine/canvas-api.js +97 -0
  261. package/src/web/spine/canvas-document.js +80 -0
  262. package/src/web/spine/canvas-engine.js +193 -0
  263. package/src/web/spine/canvas-entry.js +28 -0
  264. package/src/web/spine/canvas-pixels.js +36 -0
  265. package/src/web/spine/canvas-runtime.js +127 -0
  266. package/src/web/spine/canvas-view.js +108 -0
  267. package/src/web/spine/canvas.js +7 -0
  268. package/src/web/spine/runtime-hint.js +73 -0
  269. package/src/web/spine/runtimes.js +212 -0
  270. package/src/web/style.css +493 -0
  271. package/tool/lib/bundle-probe-node.mjs +121 -0
  272. package/tool/lib/check-perms.mjs +114 -0
  273. package/tool/lib/contract-checks.mjs +582 -0
  274. package/tool/lib/cubism-core.mjs +122 -0
  275. package/tool/lib/export-bundle.mjs +328 -0
  276. package/tool/lib/paths.mjs +20 -0
  277. package/tool/lib/runtime-guides.mjs +65 -0
  278. package/tool/lib/runtime-package.mjs +403 -0
  279. package/tool/lib/spine-meta.mjs +227 -0
  280. package/tool/lib/spine-motions.mjs +104 -0
  281. package/tool/lib/spine-runtime.mjs +131 -0
  282. package/tool/lib/test-lock.mjs +178 -0
  283. package/tool/lib/test-report.mjs +133 -0
  284. package/tool/parse-jsonc.mjs +86 -0
  285. package/tool/resolve-live2d-runtimes.mjs +276 -0
  286. package/tool/spine-motion-rules.json +101 -0
@@ -0,0 +1,647 @@
1
+ // write.mjs —— 写接口的共用管道(资源根增删改名启停 / 配置导入)
2
+ //
3
+ // 固定顺序:**校验 → 备份 → 写(tmp + rename)→ 失效缓存**。顺序不可换:
4
+ // · 校验最前:非法输入在碰磁盘之前就被拒,配置文件一个字节都不变;
5
+ // · 备份在写之前:写一旦出问题,手里还有可回滚的完整副本;
6
+ // · 写用 tmp + rename(见 core/config.mjs):rename 原子,任何时刻外部读到的
7
+ // 要么是"旧的完整版本"要么是"新的完整版本",不存在读到半截;
8
+ // · 失效缓存放最后:若先清缓存再写而写失败,会留下被删掉的清单文件,下次访问要全量重扫。
9
+ //
10
+ // 返回形态统一为 { status, body },内部不抛异常(与 data 层只读接口同形):
11
+ // 写路径的任何异常都必须在函数内部变成响应,不能让 http 回调里的未捕获异常打挂进程。
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { copyFilePortable } from '../core/fs-portable.mjs';
15
+ import { parseJsonc } from '../../../tool/parse-jsonc.mjs';
16
+
17
+ /** 请求体上限(字符):roots 配置体量很小,超过就不正常;拒绝而不是截断,避免写出坏配置 */
18
+ const MAX_BODY_CHARS = 1e6;
19
+ /** 显示名长度上限:过长会把左栏布局撑坏 */
20
+ const MAX_NAME_CHARS = 60;
21
+ /** 支持的写动作(改这里要同步左栏 UI 与文档) */
22
+ const ROOT_ACTIONS = ['add', 'remove', 'rename', 'toggle'];
23
+ /** 导入请求体上限(字符):名表可能较大,取 5MB */
24
+ const MAX_IMPORT_CHARS = 5e6;
25
+ /** 备份保留份数:更早的移入 config/.trash/(可恢复),避免反复导入把 config/ 堆满 */
26
+ const BACKUP_KEEP = 5;
27
+ /** 允许导入的文件白名单:只有资源配置与名表 —— 不给"任意写盘"的口子 */
28
+ const IMPORTABLE = [/^resources\.json$/, /^names\/[^/\\]+\.json$/];
29
+
30
+ /**
31
+ * 时间戳(用于备份目录名):本地时间 YYYYMMDD-HHmmss,可排序、人可读。
32
+ * @param {Date} d
33
+ * @returns {string}
34
+ */
35
+ function stampOf(d) {
36
+ const p2 = (n) => String(n).padStart(2, '0');
37
+ return `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-`
38
+ + `${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
39
+ }
40
+
41
+ /**
42
+ * 目录是否存在且是目录(statSync 可能因竞态抛错,这里一并吞成 false)。
43
+ * @param {string} p
44
+ * @returns {boolean}
45
+ */
46
+ function isDir(p) {
47
+ try {
48
+ return fs.statSync(p).isDirectory();
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 把资源根原始列表映射成前端视图(与 GET /api/roots 同形)。
56
+ * 存在性单独探测:配置里的路径可能已被用户挪走,前端要据此标灰。
57
+ * @param {Array<object>} list 原始资源根数组(含停用项)
58
+ * @returns {Array<object>}
59
+ */
60
+ export function toRootView(list) {
61
+ return list.map((r) => ({
62
+ id: r.id,
63
+ name: r.name,
64
+ path: r.path,
65
+ kind: r.kind || 'spine',
66
+ enabled: r.enabled !== false,
67
+ exists: isDir(r.path),
68
+ }));
69
+ }
70
+
71
+ /**
72
+ * 由目录名推导资源根 id:小写、非字母数字转下划线、去首尾下划线。
73
+ * 为什么不用随机串:id 会出现在清单文件名(models.<id>.json)里,人类可读便于排障。
74
+ * @param {string} dir
75
+ * @returns {string}
76
+ */
77
+ function idFromPath(dir) {
78
+ return path.basename(dir).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'root';
79
+ }
80
+
81
+ /**
82
+ * 校验一批待导入的配置文件(纯函数,不碰磁盘)。
83
+ *
84
+ * 校验口径(整体通过才写):
85
+ * · 文件名必须在白名单内(`resources.json` 或 `names/<名>.json`),其余一律拒绝;
86
+ * · 内容必须是非空字符串;
87
+ * · `resources.json` 要能被 JSONC 解析、`roots` 是数组、每项 id/path 齐全且 id 不重复、
88
+ * path 是绝对路径(相对路径会按进程工作目录解析,落到别处);
89
+ * · 名表要能被 JSON.parse 且结果是对象。
90
+ *
91
+ * @param {object} files 形如 { 'resources.json': '<文本>', 'names/x.json': '<文本>' }
92
+ * @returns {string[]} 错误清单;空数组表示通过
93
+ */
94
+ function validateImportFiles(files) {
95
+ const errs = [];
96
+ const entries = Object.entries(files || {});
97
+ if (!entries.length) {
98
+ errs.push('导入内容为空');
99
+ return errs;
100
+ }
101
+ for (const [rel, text] of entries) {
102
+ if (!IMPORTABLE.some((re) => re.test(rel))) {
103
+ errs.push(`${rel}: 不在允许导入的白名单内(只接受 resources.json 与 names/<名>.json)`);
104
+ continue;
105
+ }
106
+ if (typeof text !== 'string' || !text.trim()) {
107
+ errs.push(`${rel}: 内容为空或不是字符串`);
108
+ continue;
109
+ }
110
+ if (rel === 'resources.json') {
111
+ try {
112
+ const cfg = parseJsonc(text, 'resources.json');
113
+ if (!Array.isArray(cfg.roots)) {
114
+ errs.push('resources.json: 缺少 roots 数组');
115
+ continue;
116
+ }
117
+ const seen = new Set();
118
+ cfg.roots.forEach((r, i) => {
119
+ if (!r || typeof r !== 'object') {
120
+ errs.push(`resources.json: roots[${i}] 不是对象`);
121
+ return;
122
+ }
123
+ if (!r.id || typeof r.id !== 'string') errs.push(`resources.json: roots[${i}] 缺少 id`);
124
+ else if (seen.has(r.id)) errs.push(`resources.json: roots[${i}] id 重复(${r.id})`);
125
+ else seen.add(r.id);
126
+ if (!r.path || typeof r.path !== 'string') errs.push(`resources.json: roots[${i}] 缺少 path`);
127
+ else if (!path.isAbsolute(r.path)) errs.push(`resources.json: roots[${i}] path 不是绝对路径`);
128
+ });
129
+ } catch (e) {
130
+ errs.push(`resources.json 解析失败: ${e.message}`);
131
+ }
132
+ } else {
133
+ try {
134
+ const obj = JSON.parse(text);
135
+ if (!obj || typeof obj !== 'object') errs.push(`${rel}: 解析结果不是对象`);
136
+ } catch (e) {
137
+ errs.push(`${rel} 解析失败: ${e.message}`);
138
+ }
139
+ }
140
+ }
141
+ return errs;
142
+ }
143
+
144
+ /**
145
+ * 解析导入请求体(管道第①步):只判形状,不碰磁盘。
146
+ *
147
+ * 为什么单独成函数:请求体有四种"不像话"的形态(空 / 超限 / 非法 JSON / 缺 files),
148
+ * 各自对应不同状态码,混在编排里会让主流程读不出"这段在干什么"。
149
+ *
150
+ * @param {string} rawBody 原始请求体
151
+ * @returns {{ok:true, files:object} | {ok:false, status:number, error:string}}
152
+ */
153
+ function parseImportBody(rawBody) {
154
+ if (typeof rawBody !== 'string') return { ok: false, status: 400, error: '请求体为空' };
155
+ if (rawBody.length > MAX_IMPORT_CHARS) {
156
+ return { ok: false, status: 413, error: `请求体超过 ${MAX_IMPORT_CHARS} 字符` };
157
+ }
158
+ let payload;
159
+ try {
160
+ payload = JSON.parse(rawBody || '{}');
161
+ } catch {
162
+ return { ok: false, status: 400, error: '请求体不是合法 JSON' };
163
+ }
164
+ const files = payload && payload.files;
165
+ if (!files || typeof files !== 'object' || Array.isArray(files)) {
166
+ return {
167
+ ok: false,
168
+ status: 400,
169
+ error: '缺少 files 对象:{ "files": { "resources.json": "<文本>", "names/<名>.json": "<文本>" } }',
170
+ };
171
+ }
172
+ return { ok: true, files };
173
+ }
174
+
175
+ /**
176
+ * 逐文件原子写(管道第④步):每个文件先写 `.tmp` 再 rename。
177
+ *
178
+ * 为什么按文件而不是整批:rename 是原子的,单个文件不会出现"写一半";
179
+ * 整批失败时**不回滚**——已写的保持新内容、未写的保持原样,
180
+ * 两者都能从同一个备份目录整体还原(回滚交给备份,而不是半途反向写)。
181
+ *
182
+ * @param {string} configDir 配置目录
183
+ * @param {Array<{rel:string, text:string}>} entries 待写文件
184
+ * @returns {{written:string[], error:Error|null}}
185
+ */
186
+ function writeImportFiles(configDir, entries) {
187
+ const written = [];
188
+ try {
189
+ for (const { rel, text } of entries) {
190
+ const full = path.join(configDir, rel);
191
+ fs.mkdirSync(path.dirname(full), { recursive: true });
192
+ fs.writeFileSync(`${full}.tmp`, text, 'utf8');
193
+ fs.renameSync(`${full}.tmp`, full);
194
+ written.push(rel);
195
+ }
196
+ return { written, error: null };
197
+ } catch (e) {
198
+ return { written, error: e };
199
+ }
200
+ }
201
+
202
+ /**
203
+ * 回读自检(管道第⑥步):写进去的每个文件都必须能重新解析,
204
+ * 且 `resources.json` 的根数要与导入内容一致。
205
+ *
206
+ * 为什么必须回读:写盘成功不代表内容正确 —— 截断、编码破坏、清洗逻辑都可能改条数;
207
+ * 不回读就只能靠"下一次请求报错"才发现,那时已经很难判断是谁写坏的。
208
+ *
209
+ * @param {string} configDir 配置目录
210
+ * @param {string[]} written 已写文件(相对 config/)
211
+ * @param {object} files 导入内容
212
+ * @returns {{readback:object, selfCheckFailed:string|null}}
213
+ */
214
+ function selfCheckImportedFiles(configDir, written, files) {
215
+ const readback = { files: {}, rootsCount: null };
216
+ let selfCheckFailed = null;
217
+ for (const rel of written) {
218
+ try {
219
+ const text = fs.readFileSync(path.join(configDir, rel), 'utf8');
220
+ if (rel === 'resources.json') {
221
+ const cfg = parseJsonc(text, 'config/resources.json');
222
+ readback.rootsCount = Array.isArray(cfg.roots) ? cfg.roots.length : null;
223
+ } else {
224
+ JSON.parse(text);
225
+ }
226
+ readback.files[rel] = 'ok';
227
+ } catch (e) {
228
+ readback.files[rel] = `parse-error: ${e.message}`;
229
+ selfCheckFailed = `${rel}: ${e.message}`;
230
+ }
231
+ }
232
+ if (!selfCheckFailed && readback.rootsCount != null) {
233
+ const expect = parseJsonc(files['resources.json'], 'resources.json').roots.length;
234
+ if (readback.rootsCount !== expect) {
235
+ selfCheckFailed = `resources.json 根数不一致:导入 ${expect} / 实际 ${readback.rootsCount}`;
236
+ }
237
+ }
238
+ return { readback, selfCheckFailed };
239
+ }
240
+
241
+ /**
242
+ * 失效缓存(管道第⑤步):资源根可能全变 ⇒ 清全部清单;名表文件被替换 ⇒ 清名表缓存。
243
+ *
244
+ * 为什么两件事分开 try:它们互相独立,一个失败不该连累另一个;
245
+ * 失败信息汇总到 `error` 回给调用方(缓存没清只是"下次更慢",不该让导入失败)。
246
+ *
247
+ * @param {object} spine data/spine.mjs 实例
248
+ * @param {object} names data/names.mjs 实例
249
+ * @returns {{models:boolean, names:boolean, error:string|null}}
250
+ */
251
+ function invalidateImportCaches(models, names) {
252
+ const out = { models: false, names: false, error: null };
253
+ try {
254
+ spine.invalidateCaches();
255
+ out.models = true;
256
+ } catch (e) {
257
+ out.error = String((e && e.message) || e);
258
+ }
259
+ try {
260
+ if (names && typeof names.clearNameCache === 'function') {
261
+ names.clearNameCache();
262
+ out.names = true;
263
+ }
264
+ } catch (e) {
265
+ out.error = String((e && e.message) || e);
266
+ }
267
+ return out;
268
+ }
269
+
270
+ /**
271
+ * 创建写接口。
272
+ * @param {{paths: object, config: object, models: object, names: object}} deps
273
+ * paths = core/paths.mjs 的路径表(configDir / configFile / root)
274
+ * config = core/config.mjs(readConfigRaw / writeConfig / readConfig / buildGroups)
275
+ * spine = data/spine.mjs(invalidateCaches:不传参 = 失效全部)
276
+ * names = data/names.mjs(clearNameCache:导入替换名表后必须清缓存)
277
+ * @returns {{writeRoots: Function, writeConfigImport: Function}}
278
+ */
279
+ export function createWriteApi({ paths, config, models, names }) {
280
+ // 串行队列:两个写请求并发时会各自读旧配置再写回,后写的覆盖先写的(丢一次改动)。
281
+ // 排队保证「读-改-写」这一串不交错;前一个失败不影响后一个继续排队。
282
+ let queue = Promise.resolve();
283
+
284
+ /**
285
+ * 把任务排进串行队列。
286
+ * @param {() => {status:number, body:object}} task
287
+ * @returns {Promise<{status:number, body:object}>}
288
+ */
289
+ function enqueue(task) {
290
+ const run = queue.then(task, task);
291
+ queue = run.then(() => undefined, () => undefined);
292
+ return run;
293
+ }
294
+
295
+ /**
296
+ * 备份 config/ 下的一个文件到 config/.bak-<时间戳>/<相对路径>。
297
+ *
298
+ * 为什么用 `.bak-<时间戳>` 目录约定:备份要和配置文件放在一起(同一个卷、
299
+ * 同一个备份根),这样"找备份"只有一处;用目录而不是单文件后缀,是因为一次导入
300
+ * 可能涉及多个文件,同一次操作应当归到同一个备份目录里。
301
+ *
302
+ * @param {string} rel 相对 config/ 的路径,如 'resources.json'
303
+ * @returns {string|null} 备份文件的相对项目根路径;源文件不存在时返回 null(首次创建无需备份)
304
+ */
305
+ function backupConfigFile(rel) {
306
+ const src = path.join(paths.configDir, rel);
307
+ if (!fs.existsSync(src)) return null;
308
+ const stamp = stampOf(new Date());
309
+ let dir = path.join(paths.configDir, `.bak-${stamp}`);
310
+ let seq = 2;
311
+ // 同一秒内多次写:追加序号,避免覆盖上一次的备份
312
+ while (fs.existsSync(dir)) {
313
+ dir = path.join(paths.configDir, `.bak-${stamp}-${seq}`);
314
+ seq += 1;
315
+ }
316
+ fs.mkdirSync(dir, { recursive: true });
317
+ const dst = path.join(dir, rel);
318
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
319
+ // 备份用可移植复制:CIFS/SMB 上 copyFileSync 会因改权限位 EPERM
320
+ copyFilePortable(src, dst);
321
+ return path.relative(paths.root, dst).split(path.sep).join('/');
322
+ }
323
+
324
+ /**
325
+ * 批量备份一批配置文件到同一个 `.bak-<时间戳>/` 目录。
326
+ *
327
+ * 为什么"一个导入一个目录"(而不是每文件各建一个):一次导入可能改多个文件,
328
+ * 回滚时必须能整体还原到"导入前那一刻",分散在多个目录里就凑不回同一时点。
329
+ * 源文件不存在(新建的文件)跳过备份 —— 回滚时删掉即可,没有旧内容可留。
330
+ *
331
+ * @param {string[]} rels 相对 config/ 的路径列表
332
+ * @returns {string} 备份目录的相对项目根路径
333
+ */
334
+ function backupMany(rels) {
335
+ const stamp = stampOf(new Date());
336
+ let dir = path.join(paths.configDir, `.bak-${stamp}`);
337
+ let seq = 2;
338
+ while (fs.existsSync(dir)) {
339
+ dir = path.join(paths.configDir, `.bak-${stamp}-${seq}`);
340
+ seq += 1;
341
+ }
342
+ fs.mkdirSync(dir, { recursive: true });
343
+ for (const rel of rels) {
344
+ const src = path.join(paths.configDir, rel);
345
+ if (!fs.existsSync(src)) continue;
346
+ const dst = path.join(dir, rel);
347
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
348
+ copyFilePortable(src, dst);
349
+ }
350
+ return path.relative(paths.root, dir).split(path.sep).join('/');
351
+ }
352
+
353
+ /**
354
+ * 备份轮换:只保留最近 keep 份,更早的**移入 config/.trash/**(不永久删除,可恢复)。
355
+ *
356
+ * 为什么要轮换:反复导入会不断生成备份目录,堆满 config/ 且干扰排查;
357
+ * 移入 .trash/ 而不是删除,符合"删除操作可恢复"的约定(.trash/ 已在 .gitignore)。
358
+ *
359
+ * @param {number} keep 保留份数
360
+ * @returns {number} 本次移入回收站的份数
361
+ */
362
+ function pruneBackups(keep) {
363
+ const dir = paths.configDir;
364
+ const trash = path.join(dir, '.trash');
365
+ const baks = fs.readdirSync(dir).filter((n) => n.startsWith('.bak-')).sort().reverse();
366
+ const drop = baks.slice(keep);
367
+ if (!drop.length) return 0;
368
+ fs.mkdirSync(trash, { recursive: true });
369
+ for (const n of drop) {
370
+ let dst = path.join(trash, n);
371
+ let i = 2;
372
+ while (fs.existsSync(dst)) {
373
+ dst = path.join(trash, `${n}-${i}`);
374
+ i += 1;
375
+ }
376
+ fs.renameSync(path.join(dir, n), dst);
377
+ }
378
+ return drop.length;
379
+ }
380
+
381
+ /**
382
+ * 组装错误响应。
383
+ * @param {number} status
384
+ * @param {string} error
385
+ * @param {object} [extra]
386
+ * @returns {{status:number, body:object}}
387
+ */
388
+ const fail = (status, error, extra) => ({ status, body: { ok: false, error, ...(extra || {}) } });
389
+
390
+ /**
391
+ * 校验并算出「写入后应该是什么」——只做内存计算,不碰磁盘。
392
+ *
393
+ * 为什么单独抽出:校验要能整体失败(返回错误、原列表不动),
394
+ * 而不是边改边校验(那就会半改)。
395
+ *
396
+ * @param {string} action
397
+ * @param {object} payload 请求体
398
+ * @param {Array<object>} raw 当前完整资源根列表(含停用项)
399
+ * @returns {{ok:true, next:Array<object>, changed:object} | {ok:false, status:number, error:string, extra?:object}}
400
+ */
401
+ function planChange(action, payload, raw) {
402
+ const next = raw.map((r) => ({ ...r }));
403
+
404
+ if (action === 'add') {
405
+ const rawPath = String(payload.path || '').trim();
406
+ if (!rawPath) return { ok: false, status: 400, error: '缺少 path(要添加的资源根目录)' };
407
+ const dir = path.resolve(rawPath);
408
+ if (!path.isAbsolute(rawPath)) return { ok: false, status: 400, error: 'path 必须是绝对路径', path: dir };
409
+ if (!isDir(dir)) return { ok: false, status: 400, error: '目录不存在或不是目录', path: dir };
410
+ // 同一个目录登记两次会产生重复模型(列表里同一个模型出现两遍),直接拒
411
+ const dup = next.find((r) => path.resolve(String(r.path)) === dir);
412
+ if (dup) {
413
+ return {
414
+ ok: false, status: 409, error: '该目录已登记为资源根', path: dir,
415
+ existing: { id: dup.id, name: dup.name },
416
+ };
417
+ }
418
+ const wantId = String(payload.id || '').trim().toLowerCase();
419
+ const baseId = wantId || idFromPath(dir);
420
+ let id = baseId;
421
+ let seq = 2;
422
+ while (next.some((r) => r.id === id)) {
423
+ id = `${baseId}_${seq}`;
424
+ seq += 1;
425
+ }
426
+ const name = String(payload.name || '').trim() || path.basename(dir);
427
+ if (name.length > MAX_NAME_CHARS) {
428
+ return { ok: false, status: 400, error: `name 超过 ${MAX_NAME_CHARS} 个字符` };
429
+ }
430
+ // kind 是可选声明,原样透传(只有它影响扫描策略)
431
+ const root = { id, name, path: dir, enabled: payload.enabled !== false };
432
+ if (payload.kind) root.kind = String(payload.kind);
433
+ next.push(root);
434
+ return { ok: true, next, changed: { root, merged: next.filter((r) => r.name === name).length > 1 } };
435
+ }
436
+
437
+ // 其余三个动作都按 id 定位
438
+ const id = String(payload.id || '').trim();
439
+ if (!id) return { ok: false, status: 400, error: '缺少 id(要操作的资源根标识)' };
440
+ const idx = next.findIndex((r) => r.id === id);
441
+ if (idx < 0) return { ok: false, status: 404, error: '未找到该资源根', id };
442
+
443
+ if (action === 'remove') {
444
+ const [removed] = next.splice(idx, 1);
445
+ return { ok: true, next, changed: { removed } };
446
+ }
447
+
448
+ if (action === 'rename') {
449
+ const name = String(payload.name || '').trim();
450
+ if (!name) return { ok: false, status: 400, error: '缺少 name(新显示名)', id };
451
+ if (name.length > MAX_NAME_CHARS) {
452
+ return { ok: false, status: 400, error: `name 超过 ${MAX_NAME_CHARS} 个字符`, id };
453
+ }
454
+ const from = next[idx].name;
455
+ next[idx].name = name;
456
+ // name 同时是「归并键」:改成与别的根同名即表示"合并显示",这是既定语义,不算冲突
457
+ return { ok: true, next, changed: { id, from, to: name, merged: next.filter((r) => r.name === name).length > 1 } };
458
+ }
459
+
460
+ // toggle:只接受真正的布尔值,避免 "false" 字符串被当成 true
461
+ if (typeof payload.enabled !== 'boolean') {
462
+ return { ok: false, status: 400, error: 'toggle 需要布尔值 enabled', id };
463
+ }
464
+ const from = next[idx].enabled !== false;
465
+ next[idx].enabled = payload.enabled;
466
+ return { ok: true, next, changed: { id, from, to: payload.enabled } };
467
+ }
468
+
469
+ /**
470
+ * POST /api/roots —— 资源根增 / 删 / 改名 / 启停。
471
+ *
472
+ * 请求体:`{ action: 'add'|'remove'|'rename'|'toggle', ... }`
473
+ * add `{ path, name?, id?, kind?, enabled? }`
474
+ * remove `{ id }`
475
+ * rename `{ id, name }`
476
+ * toggle `{ id, enabled: boolean }`
477
+ *
478
+ * @param {string} rawBody 原始请求体文本
479
+ * @returns {Promise<{status:number, body:object}>}
480
+ */
481
+ function writeRoots(rawBody) {
482
+ return enqueue(() => {
483
+ // ---------- ① 解析与形状校验 ----------
484
+ if (typeof rawBody !== 'string') return fail(400, '请求体为空');
485
+ if (rawBody.length > MAX_BODY_CHARS) return fail(413, `请求体超过 ${MAX_BODY_CHARS} 字符`);
486
+ let payload;
487
+ try {
488
+ payload = JSON.parse(rawBody || '{}');
489
+ } catch {
490
+ return fail(400, '请求体不是合法 JSON');
491
+ }
492
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
493
+ return fail(400, '请求体必须是 JSON 对象');
494
+ }
495
+ const action = String(payload.action || 'add').trim();
496
+ if (!ROOT_ACTIONS.includes(action)) {
497
+ return fail(400, `action 不支持: ${action}`, { allowed: ROOT_ACTIONS });
498
+ }
499
+
500
+ // ---------- ② 读当前完整列表(含停用项)----------
501
+ let raw;
502
+ try {
503
+ raw = config.readConfigRaw();
504
+ } catch (e) {
505
+ // 读不出来就中止:拿空列表写回会清空用户配置
506
+ return fail(500, e.message);
507
+ }
508
+
509
+ // ---------- ③ 校验并算出目标状态(纯内存,失败则磁盘不动)----------
510
+ const plan = planChange(action, payload, raw);
511
+ if (!plan.ok) return fail(plan.status, plan.error, plan.extra);
512
+
513
+ // ---------- ④ 备份(写盘前)----------
514
+ let backup = null;
515
+ try {
516
+ backup = backupConfigFile('resources.json');
517
+ } catch (e) {
518
+ // 备份失败视为"没有回滚手段",直接放弃写入
519
+ return fail(500, `备份失败,已取消写入: ${e.message}`);
520
+ }
521
+
522
+ // ---------- ⑤ 写(tmp + rename,原子)----------
523
+ try {
524
+ config.writeConfig(plan.next);
525
+ } catch (e) {
526
+ return fail(500, `写入配置失败: ${e.message}`, { backup });
527
+ }
528
+
529
+ // ---------- ⑥ 失效缓存(只失效受影响的那个根)----------
530
+ // 为什么不能"全部失效":data/spine.mjs 的 invalidateCaches() 不带参数时会删除
531
+ // config/ 下所有 models.* 与 live2d.* 文件 —— 其中 live2d.json 是**入库的手工配置**
532
+ // (Live2D 渲染开关,带注释、留着备查),删掉等于顺手丢配置。
533
+ // 资源根写操作只影响自己那份清单,按 id 精确失效即可,其它根的缓存继续可用。
534
+ const affectedId = action === 'add' ? plan.changed.root.id
535
+ : action === 'remove' ? plan.changed.removed.id
536
+ : plan.changed.id;
537
+ const invalidated = { models: false, rootId: affectedId, error: null };
538
+ try {
539
+ spine.invalidateCaches(affectedId);
540
+ invalidated.models = true;
541
+ } catch (e) {
542
+ // 清理失败不影响本次写入结果:配置已生效,下次请求会重新生成清单
543
+ invalidated.error = String((e && e.message) || e);
544
+ }
545
+
546
+ // ---------- ⑦ 回读自检:文件里确实变成了新列表 ----------
547
+ let after = [];
548
+ try {
549
+ after = config.readConfigRaw();
550
+ } catch (e) {
551
+ return fail(500, `写入后回读失败(请检查配置): ${e.message}`, { backup });
552
+ }
553
+
554
+ // ---------- ⑧ 备份轮换(只留最近若干份,更早的移入 config/.trash/) ----------
555
+ // 为什么放在这里而不是备份之后:轮换会移走旧备份,若本次写入随后失败,
556
+ // 唯一可用的回滚点可能已经被挪进 .trash/ —— 所以必须等"写成功 + 回读自检通过"再轮换。
557
+ // 为什么资源根写操作也要轮换:add/remove/rename/toggle 每次都会生成一个 .bak-<时间戳>/,
558
+ // 之前只有 import 路径调用轮换 ⇒ 这条路径只增不减,实测堆到过上百个目录。
559
+ let pruned = 0;
560
+ try {
561
+ pruned = pruneBackups(BACKUP_KEEP);
562
+ } catch {
563
+ /* 轮换失败不影响本次写入结果(与 import 路径同口径) */
564
+ }
565
+
566
+ const body = {
567
+ ok: true,
568
+ action,
569
+ changed: plan.changed,
570
+ backup,
571
+ pruned,
572
+ invalidated,
573
+ // roots 与 GET /api/roots 同形(只含启用的,前端直接可渲染)
574
+ roots: toRootView(after.filter((r) => r.enabled !== false)),
575
+ // rootsAll 含停用项:启停操作要能看到"刚被停用的那条"
576
+ rootsAll: toRootView(after),
577
+ groups: config.buildGroups(toRootView(after.filter((r) => r.enabled !== false))),
578
+ };
579
+ return { status: 200, body };
580
+ });
581
+ }
582
+
583
+ /**
584
+ * POST /api/config/import —— 导入配置(`resources.json` + `names/*.json`)。
585
+ *
586
+ * 本函数只做**编排**,七步各自独立成函数(见本文件上方):
587
+ * 解析 parseImportBody → 校验 validateImportFiles → 备份 backupMany →
588
+ * 原子写 writeImportFiles → 失效缓存 invalidateImportCaches →
589
+ * 回读自检 selfCheckImportedFiles → 备份轮换 pruneBackups
590
+ *
591
+ * 与资源根写操作同源的两条硬规则:
592
+ * · 校验**整体拒绝**:任一文件不合法就一个字节都不写(避免配置半新半旧);
593
+ * · 备份**一个导入一个目录**:多文件改动要能整体回滚到同一时点。
594
+ *
595
+ * @param {string} rawBody 原始请求体文本
596
+ * @returns {Promise<{status:number, body:object}>}
597
+ */
598
+ function writeConfigImport(rawBody) {
599
+ return enqueue(() => {
600
+ const parsed = parseImportBody(rawBody);
601
+ if (!parsed.ok) return fail(parsed.status, parsed.error);
602
+ const { files } = parsed;
603
+
604
+ const details = validateImportFiles(files);
605
+ if (details.length) return fail(400, '配置校验失败', { details });
606
+ const entries = Object.entries(files).map(([rel, text]) => ({ rel, text }));
607
+
608
+ let backup = null;
609
+ try {
610
+ backup = backupMany(entries.map((e) => e.rel));
611
+ } catch (e) {
612
+ return fail(500, `备份失败,已取消导入: ${e.message}`);
613
+ }
614
+
615
+ const w = writeImportFiles(paths.configDir, entries);
616
+ if (w.error) {
617
+ return fail(500, `写入配置失败(已完成 ${w.written.length}/${entries.length},备份可回滚): ${w.error.message}`,
618
+ { backup, written: w.written });
619
+ }
620
+
621
+ const invalidated = invalidateImportCaches(models, names);
622
+ const { readback, selfCheckFailed } = selfCheckImportedFiles(paths.configDir, w.written, files);
623
+ if (selfCheckFailed) {
624
+ return fail(500, `导入后自检未通过(备份可回滚): ${selfCheckFailed}`,
625
+ { backup, written: w.written, readback });
626
+ }
627
+
628
+ let pruned = 0;
629
+ try { pruned = pruneBackups(BACKUP_KEEP); } catch { /* 轮换失败不影响导入结果 */ }
630
+
631
+ return {
632
+ status: 200,
633
+ body: {
634
+ ok: true,
635
+ written: w.written,
636
+ backup,
637
+ invalidated,
638
+ readback,
639
+ pruned,
640
+ roots: toRootView(config.readConfig()),
641
+ },
642
+ };
643
+ });
644
+ }
645
+
646
+ return { writeRoots, writeConfigImport };
647
+ }