deveco_hmigbot 0.21.5

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 (101) hide show
  1. package/LICENSE +26 -0
  2. package/README.md +50 -0
  3. package/agents/hmigbot-worker.md +61 -0
  4. package/agents/hmigbot.md +22 -0
  5. package/agents/workflow-subagent.md +55 -0
  6. package/commands/hmigbot.md +17 -0
  7. package/dist/index.js +1 -0
  8. package/manifest.json +19 -0
  9. package/package.json +29 -0
  10. package/skills/migrate-core/FILES.md +26 -0
  11. package/skills/migrate-core/SKILL.md +484 -0
  12. package/skills/migrate-core/references/README.md +64 -0
  13. package/skills/migrate-core/references/flow/arkts-vector-gate.md +55 -0
  14. package/skills/migrate-core/references/flow/build-error-patterns.md +52 -0
  15. package/skills/migrate-core/references/flow/conventions-template.md +244 -0
  16. package/skills/migrate-core/references/flow/navigation-migration.md +42 -0
  17. package/skills/migrate-core/references/flow/platform-api-guards.md +59 -0
  18. package/skills/migrate-core/references/flow/platform-model-gaps.md +43 -0
  19. package/skills/migrate-core/references/flow/resource-conversion.md +46 -0
  20. package/skills/migrate-core/references/flow/ui-layout-semantics.md +124 -0
  21. package/skills/migrate-core/references/flow/unit-breakdown.md +42 -0
  22. package/skills/migrate-core/references/host-capabilities.md +24 -0
  23. package/skills/migrate-core/references/topics/app-identity.md +214 -0
  24. package/skills/migrate-core/references/topics/env-doctor.md +245 -0
  25. package/skills/migrate-core/references/topics/i18n/README.md +458 -0
  26. package/skills/migrate-core/references/topics/i18n/references/code-examples.md +304 -0
  27. package/skills/migrate-core/references/topics/i18n/references/common-pitfalls.md +354 -0
  28. package/skills/migrate-core/references/topics/i18n/references/dynamic-language-switch.md +464 -0
  29. package/skills/migrate-core/references/topics/i18n/references/language-codes.md +104 -0
  30. package/skills/migrate-core/references/topics/icon-sizing.md +98 -0
  31. package/skills/migrate-core/references/topics/library-migration/README.md +234 -0
  32. package/skills/migrate-core/references/topics/library-migration/closed-source-sdk.md +128 -0
  33. package/skills/migrate-core/references/topics/library-migration/download-api-decision.md +84 -0
  34. package/skills/migrate-core/references/topics/library-migration/library-mapping-table.md +100 -0
  35. package/skills/migrate-core/references/topics/library-migration/napi-compile-guide.md +84 -0
  36. package/skills/migrate-core/references/topics/library-migration/ohpm-search-guide.md +73 -0
  37. package/skills/migrate-core/references/topics/library-migration/stdlib-mapping-table.md +34 -0
  38. package/skills/migrate-core/references/topics/resources/aar-decompile.md +25 -0
  39. package/skills/migrate-core/references/topics/resources/conversion-rules.md +625 -0
  40. package/skills/migrate-core/references/topics/resources/dependency-analysis-rules.md +328 -0
  41. package/skills/migrate-core/references/topics/resources/material-design-icons.md +173 -0
  42. package/skills/migrate-core/references/topics/resources/svg-fix-patterns.md +175 -0
  43. package/skills/migrate-core/references/topics/resources/xml-drawable-to-svg-rules.md +513 -0
  44. package/skills/migrate-core/references/topics/system-capabilities/README.md +331 -0
  45. package/skills/migrate-core/references/topics/system-capabilities/avplayer-guide.md +161 -0
  46. package/skills/migrate-core/references/topics/system-capabilities/background-tasks.md +403 -0
  47. package/skills/migrate-core/references/topics/system-capabilities/browser-intent.md +121 -0
  48. package/skills/migrate-core/references/topics/system-capabilities/camera-picker.md +118 -0
  49. package/skills/migrate-core/references/topics/system-capabilities/document-picker.md +246 -0
  50. package/skills/migrate-core/references/topics/system-capabilities/file-utils.md +131 -0
  51. package/skills/migrate-core/references/topics/system-capabilities/permission-helper.md +112 -0
  52. package/skills/migrate-core/references/topics/system-capabilities/photo-access-helper.md +208 -0
  53. package/skills/migrate-core/references/topics/system-capabilities/print-management.md +213 -0
  54. package/skills/migrate-core/references/topics/system-capabilities/share-panel.md +177 -0
  55. package/skills/migrate-core/references/topics/system-capabilities/system-settings.md +322 -0
  56. package/skills/migrate-core/references/topics/system-capabilities/telephony-dial.md +49 -0
  57. package/skills/migrate-core/references/topics/system-capabilities/video-playback.md +42 -0
  58. package/skills/migrate-core/references/topics/system-capabilities/webview-patterns.md +38 -0
  59. package/skills/migrate-core/references/topics/ui-alignment/README.md +344 -0
  60. package/skills/migrate-core/references/topics/ui-alignment/references/dark-mode.md +47 -0
  61. package/skills/migrate-core/references/topics/ui-alignment/references/layout-mapping.md +301 -0
  62. package/skills/migrate-core/references/topics/ui-alignment/references/visual-patterns.md +411 -0
  63. package/skills/migrate-core/scripts/closure/check-anchors.mjs +186 -0
  64. package/skills/migrate-core/scripts/closure/check-api-guards.mjs +175 -0
  65. package/skills/migrate-core/scripts/closure/check-consumers.mjs +301 -0
  66. package/skills/migrate-core/scripts/closure/check-permissions.mjs +165 -0
  67. package/skills/migrate-core/scripts/closure/check-resources.mjs +130 -0
  68. package/skills/migrate-core/scripts/closure/check-routes.mjs +527 -0
  69. package/skills/migrate-core/scripts/closure/check-safearea.mjs +122 -0
  70. package/skills/migrate-core/scripts/closure/check-stubs.mjs +69 -0
  71. package/skills/migrate-core/scripts/closure/closure-suite.mjs +256 -0
  72. package/skills/migrate-core/scripts/closure/idioms.json +105 -0
  73. package/skills/migrate-core/scripts/convert/convert-resources.mjs +437 -0
  74. package/skills/migrate-core/scripts/feasibility/feasibility.mjs +235 -0
  75. package/skills/migrate-core/scripts/feasibility/tables/cross-platform.json +11 -0
  76. package/skills/migrate-core/scripts/feasibility/tables/deprecated-api.json +10 -0
  77. package/skills/migrate-core/scripts/feasibility/tables/imported-arkts-core.json +425 -0
  78. package/skills/migrate-core/scripts/feasibility/tables/lib-equivalence.json +206 -0
  79. package/skills/migrate-core/scripts/feasibility/tables/system-capabilities.json +22 -0
  80. package/skills/migrate-core/scripts/front.mjs +107 -0
  81. package/skills/migrate-core/scripts/interface/ark-extract.mjs +172 -0
  82. package/skills/migrate-core/scripts/interface/interface.mjs +152 -0
  83. package/skills/migrate-core/scripts/ledger/ledger.mjs +383 -0
  84. package/skills/migrate-core/scripts/ledger/parse-cards.mjs +98 -0
  85. package/skills/migrate-core/scripts/lib/literals.mjs +37 -0
  86. package/skills/migrate-core/scripts/lib/scan.mjs +315 -0
  87. package/skills/migrate-core/scripts/smoke/align-sdk.mjs +118 -0
  88. package/skills/migrate-core/scripts/smoke/ensure-sign.mjs +53 -0
  89. package/skills/migrate-core/scripts/smoke/smoke.mjs +238 -0
  90. package/skills/migrate-core/scripts/smoke/verdict.mjs +31 -0
  91. package/skills/migrate-core/scripts/smoke/walk.mjs +480 -0
  92. package/skills/migrate-core/scripts/transpile/mapping.json +76 -0
  93. package/skills/migrate-core/scripts/transpile/transpile-layout.mjs +404 -0
  94. package/skills/migrate-core/scripts/vectors/run-arkts-vectors.mjs +107 -0
  95. package/skills/migrate-core/scripts/vectors/setup-arkts-test.mjs +90 -0
  96. package/skills/migrate-core/scripts/wire/extractors.mjs +258 -0
  97. package/skills/migrate-core/scripts/wire/wire-routes.mjs +507 -0
  98. package/skills/migrate-core/templates/acceptance.js +365 -0
  99. package/skills/migrate-core/templates/explore.js +86 -0
  100. package/skills/migrate-core/templates/implement.js +211 -0
  101. package/skills/migrate-core/templates/mig_slices.js +491 -0
@@ -0,0 +1,437 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Android resources → HarmonyOS resources 确定性转换(通用版)。
4
+ *
5
+ * 来源:在真实迁移项目中验证过的转换脚本,去掉了应用特定的路径与主题 token,
6
+ * I/O 全部参数化;转换逻辑(字符串清洗、locale 映射、vector→SVG)保持原样。
7
+ *
8
+ * 用法:
9
+ * node convert-resources.mjs --src <Android 源码根> --dst <HarmonyOS 工程根> --out <工作目录> [--app-name <名字>] [--module <模块名>=entry]
10
+ *
11
+ * 做什么:
12
+ * - 自动发现 $SRC 下所有 *\/src/main/res 目录(跳过 build/、测试目录)
13
+ * - strings.xml / plurals → <module>/src/main/resources/<locale>/element/{string,plural}.json(base + 多语言)
14
+ * - colors.xml → base/element/color.json;values-night/colors.xml → dark/element/color.json
15
+ * - vector drawable XML → base/media/<name>.svg(clip-path 丢弃并告警;非 vector 记 skipped)
16
+ * - mipmap 应用图标(ic_launcher*)拷到 base/media 与 AppScope
17
+ * - integers/dimens 汇总进清单(不落资源,供实现单元当常量参考)
18
+ * - 产出 <out>/resource-manifest.json(统计 + 图标清单 + skipped + warnings + 冲突记录)
19
+ *
20
+ * 不做什么(留给实现单元):shape/selector/layer-list 等非 vector drawable、9-patch、主题 token 设计。
21
+ */
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+
25
+ // ---------- args ----------
26
+ function argOf(flag) {
27
+ const i = process.argv.indexOf(flag);
28
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : undefined;
29
+ }
30
+ const SRC = argOf('--src');
31
+ const DST = argOf('--dst');
32
+ const OUT = argOf('--out');
33
+ const APP_NAME = argOf('--app-name');
34
+ const MODULE = argOf('--module') || 'entry'; // 目标模块名(多模块工程用 --module 指定)
35
+ // AAR/库资源合并:把解包 AAR 的 res 目录(可多个,逗号分隔)并入转换集——库资源缺口的轻量解法;
36
+ // 未解包的 .aar 先 unzip 取其 res/ 再传入(.aar 即 zip)。反编合并指引见 references/topics/resources/aar-decompile.md。
37
+ const EXTRA_RES = (argOf('--extra-res') || '').split(',').map(s => s.trim()).filter(Boolean);
38
+ if (!SRC || !DST || !OUT) {
39
+ console.error('usage: node convert-resources.mjs --src <android root> --dst <harmony project> --out <workdir> [--app-name <name>] [--module <name>=entry] [--extra-res <AAR的res,..>]');
40
+ process.exit(1);
41
+ }
42
+ const skipped = [];
43
+ const warnings = [];
44
+ const conflicts = [];
45
+
46
+ // ---------- helpers ----------
47
+ function read(p) { return fs.readFileSync(p, 'utf8'); }
48
+ function write(p, s) { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, s); }
49
+ function decodeXmlEntities(s) {
50
+ return s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
51
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
52
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
53
+ .replace(/&apos;/g, "'").replace(/&amp;/g, '&');
54
+ }
55
+ function unescapeAndroid(s) {
56
+ let out = ''; let i = 0;
57
+ while (i < s.length) {
58
+ const c = s[i];
59
+ if (c === '\\' && i + 1 < s.length) {
60
+ const n = s[i + 1];
61
+ if (n === 'n') out += '\n'; else if (n === 't') out += '\t';
62
+ else if (n === '\'') out += "'"; else if (n === '"') out += '"';
63
+ else if (n === '\\') out += '\\'; else if (n === ' ') out += ' ';
64
+ else { out += n; }
65
+ i += 2;
66
+ } else { out += c; i += 1; }
67
+ }
68
+ return out;
69
+ }
70
+ function cleanText(raw) {
71
+ let s = (raw ?? '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1');
72
+ s = decodeXmlEntities(s);
73
+ s = s.replace(/<xliff:g[^>]*>/g, '').replace(/<\/xliff:g>/g, '');
74
+ s = s.replace(/<[^>]+>/g, '');
75
+ s = unescapeAndroid(s);
76
+ return s.trim();
77
+ }
78
+
79
+ // ---------- discover res dirs ----------
80
+ function findResDirs(root) {
81
+ const out = [];
82
+ const walk = (dir, depth) => {
83
+ if (depth > 8) return;
84
+ let entries;
85
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
86
+ for (const e of entries) {
87
+ if (!e.isDirectory()) continue;
88
+ const name = e.name;
89
+ if (name === 'build' || name === '.git' || name === 'node_modules' || name.startsWith('.')) continue;
90
+ if (name === 'test' || name === 'androidTest' || name === 'sharedTest') continue;
91
+ const p = path.join(dir, name);
92
+ if (name === 'res' && dir.endsWith(path.join('src', 'main'))) { out.push(p); continue; }
93
+ walk(p, depth + 1);
94
+ }
95
+ };
96
+ walk(root, 0);
97
+ // app 模块优先(同名资源冲突时 app 的先到先得)
98
+ return out.sort((a, b) => {
99
+ const aApp = /\/app\//.test(String(a).replace(/\\/g, '/')) ? 0 : 1; // Windows 反斜杠路径也认
100
+ const bApp = /\/app\//.test(String(b).replace(/\\/g, '/')) ? 0 : 1;
101
+ return aApp - bApp || a.localeCompare(b);
102
+ });
103
+ }
104
+ const resDirs = findResDirs(SRC);
105
+ // 库资源排在应用资源之后:同名冲突时应用侧先到先得,AAR 只补缺
106
+ for (const er of EXTRA_RES) {
107
+ if (fs.existsSync(er)) resDirs.push(er);
108
+ else console.error(`warn: --extra-res 不存在,跳过:${er}`);
109
+ }
110
+ if (!resDirs.length) { console.error(`no */src/main/res found under ${SRC}`); process.exit(1); }
111
+
112
+ // ---------- locale mapping ----------
113
+ const ANDROID_TO_OH_LOCALE = (dir) => {
114
+ const m = dir.match(/^values-(.+)$/); if (!m) return null;
115
+ let l = m[1];
116
+ if (/^(night|land|port|v\d+|w\d+dp|h\d+dp|sw\d+dp|ldpi|mdpi|hdpi|xhdpi|xxhdpi|xxxhdpi)$/.test(l)) return null; // 非语言限定符
117
+ l = l.replace(/^zh-rCN$/, 'zh_CN').replace(/^zh-rTW$/, 'zh_TW').replace(/^zh-rHK$/, 'zh_HK')
118
+ .replace(/^pt-rBR$/, 'pt_BR').replace(/^kn-rIN$/, 'kn_IN')
119
+ .replace(/^in$/, 'id').replace(/^iw$/, 'he')
120
+ .replace(/^b\+([a-z]+)\+([A-Za-z0-9]+)(?:\+([A-Za-z0-9]+))?$/, (_m, lang, a, b) => b ? `${lang}_${a}_${b}` : `${lang}_${a}`)
121
+ .replace(/^([a-z]{2,3})-r([A-Za-z0-9]+)$/, '$1_$2');
122
+ // 目标平台限定符语法:语言 2–3 小写;可选文字 4 字母(首大写);可选地区 2 大写字母或 3 位数字。
123
+ // 地区两字母统一大写;变体/方言标签(如 4 字母以上的地区位)不在语法内 → 整个 locale 跳过并告警,不让一个目录名弄红整个构建
124
+ const seg = l.split('_');
125
+ if (seg.length > 1 && /^[a-z]{2}$/.test(seg[seg.length - 1])) seg[seg.length - 1] = seg[seg.length - 1].toUpperCase();
126
+ l = seg.join('_');
127
+ if (/^[a-z]{2,3}(_[A-Z][a-z]{3})?(_[A-Z]{2}|_\d{3})?$/.test(l)) return l;
128
+ warnings.push(`locale ${dir} → ${l} 不符合目标平台限定符语法,已跳过`);
129
+ return null;
130
+ };
131
+
132
+ // ---------- strings + plurals(多 res 目录合并,先到先得,冲突记录) ----------
133
+ const stringsByLocale = new Map(); // ohLocale -> Map(name->value)
134
+ const pluralsByLocale = new Map(); // ohLocale -> Map(name->[{quantity,value}])
135
+ for (const resDir of resDirs) {
136
+ let valueDirs;
137
+ try { valueDirs = fs.readdirSync(resDir).filter((d) => d.startsWith('values')); } catch { continue; }
138
+ for (const dir of valueDirs) {
139
+ // 不按文件名找:AAR/库资源是合并式 values.xml——读目录下全部 xml 提取标签
140
+ let xmlFiles;
141
+ try { xmlFiles = fs.readdirSync(path.join(resDir, dir)).filter((x) => x.endsWith('.xml')); } catch { continue; }
142
+ if (!xmlFiles.length) continue;
143
+ const oh = dir === 'values' ? 'base' : ANDROID_TO_OH_LOCALE(dir);
144
+ if (!oh) continue;
145
+ const xml = xmlFiles.map((x) => read(path.join(resDir, dir, x))).join('\n');
146
+ if (!stringsByLocale.has(oh)) stringsByLocale.set(oh, new Map());
147
+ if (!pluralsByLocale.has(oh)) pluralsByLocale.set(oh, new Map());
148
+ const sm = stringsByLocale.get(oh); const pm = pluralsByLocale.get(oh);
149
+ const put = (name, value) => {
150
+ if (sm.has(name)) { if (sm.get(name) !== value) conflicts.push(`string ${oh}/${name} (kept first)`); return; }
151
+ sm.set(name, value);
152
+ };
153
+ for (const m of xml.matchAll(/<string\s+name="([^"]+)"([^>]*?)\/>/g)) put(m[1], '');
154
+ for (const m of xml.matchAll(/<string\s+name="([^"]+)"([^>]*)>([\s\S]*?)<\/string>/g)) put(m[1], cleanText(m[3]));
155
+ for (const m of xml.matchAll(/<plurals\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/plurals>/g)) {
156
+ if (pm.has(m[1])) continue;
157
+ // 自闭合空项(<item quantity="many"/>,翻译平台留的占位)与实体项一趟按文档序解析——分两趟时
158
+ // 实体项正则会从自闭合标签越过 `/>` 吞到下一项的 </item>,产出重复 quantity(构建报 Duplicated quantity)。
159
+ // 空值不进(plural 项须有值),同 quantity 只留首个。
160
+ const items = []; const seenQ = new Set();
161
+ for (const im of m[2].matchAll(/<item\s+quantity="([^"]+)"[^>]*?(\/>|>([\s\S]*?)<\/item>)/g)) {
162
+ const value = im[2] === '/>' ? '' : cleanText(im[3]);
163
+ if (!value || seenQ.has(im[1])) continue;
164
+ seenQ.add(im[1]); items.push({ quantity: im[1], value });
165
+ }
166
+ // 目标平台要求 other 必在:源里 other 为空占位时用最接近的复数档补齐(many > few > 末项),不让整个 locale 构建失败
167
+ if (items.length && !seenQ.has('other')) {
168
+ const fill = items.find((i) => i.quantity === 'many') || items.find((i) => i.quantity === 'few') || items[items.length - 1];
169
+ items.push({ quantity: 'other', value: fill.value });
170
+ warnings.push(`plural ${oh}/${m[1]}: other 缺失,以 ${fill.quantity} 档补齐`);
171
+ }
172
+ if (items.length) pm.set(m[1], items);
173
+ }
174
+ }
175
+ }
176
+ if (!stringsByLocale.has('base')) stringsByLocale.set('base', new Map());
177
+ const baseMap = stringsByLocale.get('base');
178
+
179
+ // app 名:--app-name 优先,其次源 app_name,最后保留骨架里的 EntryAbility_label
180
+ const appName = APP_NAME || baseMap.get('app_name') || undefined;
181
+ if (appName) baseMap.set('app_name', appName);
182
+
183
+ // 写 string.json / plural.json(保留骨架里已有、源没有的 name)
184
+ const skeletonStrings = path.join(DST, MODULE, 'src/main/resources/base/element/string.json');
185
+ let keepStrings = [];
186
+ try { keepStrings = JSON.parse(read(skeletonStrings)).string || []; } catch { keepStrings = []; }
187
+ for (const oh of stringsByLocale.keys()) {
188
+ const map = stringsByLocale.get(oh);
189
+ if (!map.size) continue;
190
+ if (appName && !map.has('app_name')) map.set('app_name', appName);
191
+ const arr = [];
192
+ if (oh === 'base') {
193
+ for (const k of keepStrings) if (!map.has(k.name)) arr.push({ name: k.name, value: k.value });
194
+ }
195
+ for (const [name, value] of map) arr.push({ name, value });
196
+ if (oh === 'base' && appName) {
197
+ const idx = arr.findIndex((x) => x.name === 'EntryAbility_label');
198
+ if (idx >= 0) arr[idx].value = appName; else arr.push({ name: 'EntryAbility_label', value: appName });
199
+ }
200
+ const dir = oh === 'base' ? 'base' : oh;
201
+ write(path.join(DST, MODULE, `src/main/resources/${dir}/element/string.json`), JSON.stringify({ string: arr }, null, 2));
202
+ const pm = pluralsByLocale.get(oh);
203
+ if (pm && pm.size) {
204
+ const pl = [...pm.entries()].map(([name, value]) => ({ name, value }));
205
+ write(path.join(DST, MODULE, `src/main/resources/${dir}/element/plural.json`), JSON.stringify({ plural: pl }, null, 2));
206
+ }
207
+ }
208
+ if (appName) {
209
+ write(path.join(DST, 'AppScope/resources/base/element/string.json'),
210
+ JSON.stringify({ string: [{ name: 'app_name', value: appName }] }, null, 2));
211
+ }
212
+
213
+ // ---------- colors(base + night→dark) ----------
214
+ function parseColorsFile(f, into) {
215
+ if (!fs.existsSync(f)) return;
216
+ for (const m of read(f).matchAll(/<color\s+name="([^"]+)"[^>]*>([^<]+)<\/color>/g)) {
217
+ const name = m[1]; const value = m[2].trim();
218
+ if (into.has(name)) { if (into.get(name) !== value) conflicts.push(`color ${name} (kept first)`); continue; }
219
+ into.set(name, value);
220
+ }
221
+ }
222
+ // 引用解析:主题色表常见 `@color/x` 转引(整表可能全是引用,直接丢会得到 0 色 + 布局悬空),在同表内递归解到字面量,
223
+ // 夜间表回退基表;@android:color/、?attr/ 与解不到的库色告警剔除。
224
+ function resolveColorRefs(map, fallback) {
225
+ for (const [name, raw] of [...map.entries()]) {
226
+ let v = raw, hops = 0;
227
+ while (typeof v === 'string' && v.startsWith('@') && hops++ < 6) {
228
+ const ref = (v.match(/^@color\/([\w.]+)$/) || [])[1];
229
+ v = ref ? (map.get(ref) ?? (fallback ? fallback.get(ref) : undefined) ?? null) : null;
230
+ }
231
+ if (typeof v === 'string' && /^#[0-9A-Fa-f]{3,8}$/.test(v)) map.set(name, v);
232
+ else { warnings.push(`color ${name} = ${raw} unresolved (dropped)`); map.delete(name); }
233
+ }
234
+ }
235
+ const baseColors = new Map(); const nightColors = new Map();
236
+ for (const resDir of resDirs) {
237
+ for (const [sub, map] of [['values', baseColors], ['values-night', nightColors]]) {
238
+ let xs; try { xs = fs.readdirSync(path.join(resDir, sub)).filter((x) => x.endsWith('.xml')); } catch { continue; }
239
+ for (const x of xs) parseColorsFile(path.join(resDir, sub, x), map);
240
+ }
241
+ }
242
+ resolveColorRefs(baseColors, null);
243
+ resolveColorRefs(nightColors, baseColors);
244
+ function normHex(v) {
245
+ // Android #AARRGGBB → 保留 8 位(HarmonyOS 支持 #AARRGGBB);其余原样大写
246
+ return v.toUpperCase();
247
+ }
248
+ if (baseColors.size) {
249
+ const baseColorF = path.join(DST, MODULE, 'src/main/resources/base/element/color.json');
250
+ let keepColors = [];
251
+ try { keepColors = JSON.parse(read(baseColorF)).color || []; } catch { keepColors = []; }
252
+ const arr = keepColors.filter((c) => !baseColors.has(c.name))
253
+ .concat([...baseColors.entries()].map(([name, value]) => ({ name, value: normHex(value) })));
254
+ write(baseColorF, JSON.stringify({ color: arr }, null, 2));
255
+ }
256
+ if (nightColors.size) {
257
+ const darkColorF = path.join(DST, MODULE, 'src/main/resources/dark/element/color.json');
258
+ let keepDark = [];
259
+ try { keepDark = JSON.parse(read(darkColorF)).color || []; } catch { keepDark = []; }
260
+ const arr = keepDark.filter((c) => !nightColors.has(c.name))
261
+ .concat([...nightColors.entries()].map(([name, value]) => ({ name, value: normHex(value) })));
262
+ write(darkColorF, JSON.stringify({ color: arr }, null, 2));
263
+ }
264
+
265
+ // ---------- vector drawable → svg ----------
266
+ const ANDROID_COLOR_MAP = { '@android:color/white': '#FFFFFF', '@android:color/black': '#000000', '#FFF': '#FFFFFF', '#000': '#000000' };
267
+ function normColor(v, fallback) {
268
+ if (!v) return { c: fallback, a: 1 };
269
+ v = v.trim();
270
+ if (ANDROID_COLOR_MAP[v]) return { c: ANDROID_COLOR_MAP[v], a: 1 };
271
+ if (v.startsWith('?attr') || v.startsWith('?android')) return { c: fallback, a: 1 };
272
+ if (v.startsWith('@color/')) { const c = baseColors.get(v.slice(7)); if (c) return normColor(c, fallback); return { c: fallback, a: 1 }; }
273
+ if (v.startsWith('#')) {
274
+ if (v.length === 9) return { c: '#' + v.slice(3), a: parseInt(v.slice(1, 3), 16) / 255 };
275
+ if (v.length === 7) return { c: v, a: 1 };
276
+ if (v.length === 5) return { c: '#' + v.slice(1).split('').map((x) => x + x).join('').slice(0, 6), a: 1 };
277
+ if (v.length === 4) return { c: '#' + v.slice(1).split('').map((x) => x + x).join(''), a: 1 };
278
+ }
279
+ return { c: fallback, a: 1 };
280
+ }
281
+ function attrs(s) {
282
+ const o = {};
283
+ for (const m of s.matchAll(/([\w:.%-]+)="([^"]*)"/g)) o[m[1]] = m[2];
284
+ return o;
285
+ }
286
+ let gradCounter = 0;
287
+ function pathSvg(pa, defs) {
288
+ const a = attrs(pa);
289
+ let fill = 'none'; let fillOpacity = '';
290
+ const gradMatch = pa.match(/<aapt:attr\s+name="android:fillColor"[^>]*>([\s\S]*?)<\/aapt:attr>/);
291
+ if (gradMatch) {
292
+ const g = attrs(gradMatch[1].match(/<gradient[^>]*>/)[0]);
293
+ const id = `g${gradCounter++}`;
294
+ const stops = [];
295
+ for (const sm of gradMatch[1].matchAll(/<item\s+([^>]*?)\/>/g)) {
296
+ const ia = attrs(sm[1]);
297
+ stops.push(` <stop offset="${ia['android:offset']}" stop-color="${normColor(ia['android:color'], '#000000').c}"/>`);
298
+ }
299
+ defs.push(` <linearGradient id="${id}" x1="${g['android:startX'] ?? 0}" y1="${g['android:startY'] ?? 0}" x2="${g['android:endX'] ?? 1}" y2="${g['android:endY'] ?? 0}">\n${stops.join('\n')}\n </linearGradient>`);
300
+ fill = `url(#${id})`;
301
+ } else {
302
+ const nc = normColor(a['android:fillColor'], '#000000');
303
+ if (a['android:fillColor'] !== undefined) { fill = nc.c; fillOpacity = (nc.a * parseFloat(a['android:fillAlpha'] ?? '1')).toFixed(3); }
304
+ }
305
+ if (a['android:strokeColor'] !== undefined) {
306
+ const sc = normColor(a['android:strokeColor'], '#000000');
307
+ return `<path d="${a['android:pathData']}" fill="${fill}"${fillOpacity && fillOpacity !== '1.000' ? ` fill-opacity="${fillOpacity}"` : ''} stroke="${sc.c}"${a['android:strokeWidth'] ? ` stroke-width="${a['android:strokeWidth']}"` : ' stroke-width="1"'}/>`;
308
+ }
309
+ return `<path d="${a['android:pathData']}" fill="${fill}"${fillOpacity && fillOpacity !== '1.000' ? ` fill-opacity="${fillOpacity}"` : ''}${a['android:fillType'] === 'evenOdd' ? ' fill-rule="evenodd"' : ''}/>`;
310
+ }
311
+ function groupTransform(a) {
312
+ const px = parseFloat(a['android:pivotX'] ?? 0); const py = parseFloat(a['android:pivotY'] ?? 0);
313
+ const tx = parseFloat(a['android:translateX'] ?? 0); const ty = parseFloat(a['android:translateY'] ?? 0);
314
+ const r = parseFloat(a['android:rotation'] ?? 0);
315
+ const sx = parseFloat(a['android:scaleX'] ?? 1); const sy = parseFloat(a['android:scaleY'] ?? 1);
316
+ return `translate(${px + tx} ${py + ty}) rotate(${r}) scale(${sx} ${sy}) translate(${-px} ${-py})`;
317
+ }
318
+ function vectorToSvg(xml, name) {
319
+ const rootM = xml.match(/<vector[^>]*>/); if (!rootM) return null;
320
+ const ra = attrs(rootM[0]);
321
+ const w = (ra['android:width'] || '24dp').replace('dp', '');
322
+ const h = (ra['android:height'] || '24dp').replace('dp', '');
323
+ const vw = ra['android:viewportWidth'] || w; const vh = ra['android:viewportHeight'] || h;
324
+ const defs = []; const body = [];
325
+ const stack = [];
326
+ const tagRe = /<\/?(group|path|clip-path|aapt:attr|vector)[^>]*?\/?>/g;
327
+ const region = xml.slice(rootM.index + rootM[0].length, xml.lastIndexOf('</vector>'));
328
+ for (const t of region.matchAll(tagRe)) {
329
+ const tag = t[0];
330
+ if (tag.startsWith('<group')) stack.push(`<g transform="${groupTransform(attrs(tag))}">`);
331
+ else if (tag.startsWith('</group')) { stack.push('</g>'); const g = stack.pop(); if (g && g.startsWith('<g')) body.push(g); const c = stack.pop(); if (c) body.push(c); }
332
+ else if (tag.startsWith('<path')) body.push(pathSvg(region.slice(t.index, region.indexOf('/>', t.index) + 2), defs));
333
+ else if (tag.startsWith('<clip-path')) warnings.push(`${name}: clip-path dropped`);
334
+ }
335
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${vw} ${vh}">\n${defs.length ? `<defs>\n${defs.join('\n')}\n</defs>\n` : ''}${body.join('\n')}\n</svg>\n`;
336
+ }
337
+ const icons = [];
338
+ const iconSeen = new Set();
339
+ for (const resDir of resDirs) {
340
+ let dirs;
341
+ try { dirs = fs.readdirSync(resDir).filter((d) => d === 'drawable' || d.startsWith('drawable-')); } catch { continue; }
342
+ for (const d of dirs) {
343
+ const full = path.join(resDir, d);
344
+ for (const f of fs.readdirSync(full)) {
345
+ if (!f.endsWith('.xml')) { if (!/\.(png|jpe?g|webp|gif)$/i.test(f)) skipped.push(`${path.relative(SRC, full)}/${f} (non-xml)`); continue; } // 位图另段处理
346
+ const name = f.replace('.xml', '');
347
+ if (iconSeen.has(name)) continue;
348
+ const xml = read(path.join(full, f));
349
+ if (!/<vector[\s>]/.test(xml)) { skipped.push(`${path.relative(SRC, full)}/${f} (not vector: ${(xml.match(/<(\w[\w-]*)[\s>]/) || [])[1]})`); continue; }
350
+ const svg = vectorToSvg(xml, name);
351
+ if (svg) { write(path.join(DST, MODULE, `src/main/resources/base/media/${name}.svg`), svg); icons.push(name); iconSeen.add(name); }
352
+ else skipped.push(`${path.relative(SRC, full)}/${f} (convert fail)`);
353
+ }
354
+ }
355
+ }
356
+
357
+ // ---------- 位图(drawable*/mipmap* 下的 png/jpg/webp/gif)→ base/media:不搬则布局里 $r('app.media.x') 悬空(实测 3 个应用骨架期即硬违规) ----------
358
+ // mipmap 目录是任意可引用图片资源(@mipmap/x 与 @drawable/x 同为 $r('app.media.x')),不只放启动图标。
359
+ // 同名多密度按 nodpi > xxxhdpi > … > 无限定符 取一张(媒体只需一份最高清);未列出的限定符目录排最后;9-patch 无等价记 skipped。
360
+ const RASTER_DENS = ['drawable-nodpi', 'mipmap-nodpi', 'drawable-xxxhdpi', 'mipmap-xxxhdpi', 'drawable-xxhdpi', 'mipmap-xxhdpi', 'drawable-xhdpi', 'mipmap-xhdpi', 'drawable-hdpi', 'mipmap-hdpi', 'drawable-mdpi', 'mipmap-mdpi', 'drawable', 'mipmap'];
361
+ const rasters = [];
362
+ for (const resDir of resDirs) {
363
+ let dirs; try { dirs = fs.readdirSync(resDir).filter((d) => /^(drawable|mipmap)(-|$)/.test(d)); } catch { continue; }
364
+ const rank = (d) => (RASTER_DENS.indexOf(d) < 0 ? 99 : RASTER_DENS.indexOf(d));
365
+ dirs.sort((a, b) => rank(a) - rank(b));
366
+ for (const d of dirs) {
367
+ const full = path.join(resDir, d);
368
+ for (const f of fs.readdirSync(full)) {
369
+ const m = f.match(/^(.+?)(\.9)?\.(png|jpe?g|webp|gif)$/i); if (!m) continue;
370
+ if (m[2]) { skipped.push(`${path.relative(SRC, full)}/${f} (9-patch 无等价)`); continue; }
371
+ const name = m[1]; if (iconSeen.has(name)) continue;
372
+ const ext = m[3].toLowerCase() === 'jpeg' ? 'jpg' : m[3].toLowerCase();
373
+ const dst = path.join(DST, MODULE, `src/main/resources/base/media/${name}.${ext}`);
374
+ fs.mkdirSync(path.dirname(dst), { recursive: true }); fs.copyFileSync(path.join(full, f), dst);
375
+ iconSeen.add(name); rasters.push(name);
376
+ }
377
+ }
378
+ }
379
+
380
+ // ---------- app icons (mipmap png,取最高密度可用的) ----------
381
+ const DENSITIES = ['mipmap-xxxhdpi', 'mipmap-xxhdpi', 'mipmap-xhdpi', 'mipmap-hdpi', 'mipmap-mdpi', 'mipmap'];
382
+ outer:
383
+ for (const resDir of resDirs) {
384
+ for (const den of DENSITIES) {
385
+ const mip = path.join(resDir, den);
386
+ if (!fs.existsSync(mip)) continue;
387
+ let copied = false;
388
+ for (const [srcName, dstName] of [['ic_launcher_foreground.png', 'foreground.png'], ['ic_launcher_background.png', 'background.png'], ['ic_launcher.png', 'ic_launcher.png']]) {
389
+ const f = path.join(mip, srcName);
390
+ if (fs.existsSync(f)) {
391
+ for (const dst of [path.join(DST, MODULE, 'src/main/resources/base/media'), path.join(DST, 'AppScope/resources/base/media')]) {
392
+ fs.mkdirSync(dst, { recursive: true }); fs.copyFileSync(f, path.join(dst, dstName));
393
+ }
394
+ copied = true;
395
+ }
396
+ }
397
+ if (copied) {
398
+ // 单图场景(源无分层 png,仅 ic_launcher.png):icon 引用从模板 layered_image 切到单图,
399
+ // 否则桌面仍显示模板分层图标(搬运成功但引用没切,实测坑)
400
+ const gotForeground = fs.existsSync(path.join(DST, 'AppScope/resources/base/media', 'foreground.png')) &&
401
+ fs.existsSync(path.join(mip, 'ic_launcher_foreground.png'));
402
+ if (!gotForeground && fs.existsSync(path.join(mip, 'ic_launcher.png'))) {
403
+ for (const cfg of [path.join(DST, 'AppScope/app.json5'), path.join(DST, MODULE, 'src/main/module.json5')]) {
404
+ if (!fs.existsSync(cfg)) continue;
405
+ const t0 = fs.readFileSync(cfg, 'utf8');
406
+ const t1 = t0.replace(/"icon"\s*:\s*"\$media:layered_image"/g, '"icon": "$media:ic_launcher"');
407
+ if (t1 !== t0) { fs.writeFileSync(cfg, t1); warnings.push(`icon 引用切换为单图 $media:ic_launcher(源无分层 png):${cfg}`); }
408
+ }
409
+ }
410
+ break outer;
411
+ }
412
+ }
413
+ }
414
+
415
+ // ---------- integers/dimens(进清单,供实现单元当常量参考) ----------
416
+ const integers = {}; const dimens = {};
417
+ for (const resDir of resDirs) {
418
+ for (const file of ['values/integers.xml', 'values/dimens.xml']) {
419
+ const f = path.join(resDir, file);
420
+ if (!fs.existsSync(f)) continue;
421
+ const xml = read(f);
422
+ for (const m of xml.matchAll(/<integer\s+name="([^"]+)"[^>]*>([^<]+)<\/integer>/g)) if (!(m[1] in integers)) integers[m[1]] = parseInt(m[2].trim());
423
+ for (const m of xml.matchAll(/<dimen\s+name="([^"]+)"[^>]*>([^<]+)<\/dimen>/g)) if (!(m[1] in dimens)) dimens[m[1]] = m[2].trim();
424
+ }
425
+ }
426
+
427
+ // ---------- manifest ----------
428
+ const manifest = {
429
+ resDirs: resDirs.map((d) => path.relative(SRC, d)),
430
+ strings: { base: baseMap.size, locales: [...stringsByLocale.keys()].filter((k) => k !== 'base' && stringsByLocale.get(k).size).length },
431
+ plurals: (pluralsByLocale.get('base') || new Map()).size,
432
+ colors: { base: baseColors.size, dark: nightColors.size },
433
+ icons: icons.sort(), rasters: rasters.sort(),
434
+ integers, dimens, conflicts, skipped, warnings,
435
+ };
436
+ write(path.join(OUT, 'resource-manifest.json'), JSON.stringify(manifest, null, 2));
437
+ console.log(`resDirs=${resDirs.length} strings(base)=${baseMap.size} locales=${manifest.strings.locales} plurals=${manifest.plurals} colors=${baseColors.size}+${nightColors.size}dark icons=${icons.length}+${rasters.length}raster conflicts=${conflicts.length} skipped=${skipped.length} warnings=${warnings.length}`);