android2harmony 0.1.4 → 0.1.6

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 (116) hide show
  1. package/README.md +1 -407
  2. package/agents/self-tester.md +55 -376
  3. package/dist/index.js +238 -124
  4. package/dist/index.js.map +4 -4
  5. package/package.json +36 -32
  6. package/skills/a2h-resource-convert/SKILL.md +931 -0
  7. package/skills/a2h-resource-convert/references/code-vector-icon-rules.md +335 -0
  8. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/conversion-rules.md +15 -2
  9. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/dependency-analysis-rules.md +24 -2
  10. package/skills/a2h-resource-convert/references/lottie-conversion-rules.md +219 -0
  11. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/resource-mapping-rules.md +27 -2
  12. package/skills/{hmos-resources-convert → a2h-resource-convert}/references/xml-drawable-to-svg-rules.md +118 -1
  13. package/skills/a2h-resource-convert/scripts/a2h_resource_convert.js +2186 -0
  14. package/skills/a2h-resource-convert/scripts/app_identity.js +741 -0
  15. package/skills/a2h-resource-convert/scripts/code_vector_icons.js +607 -0
  16. package/skills/a2h-resource-convert/scripts/package.json +3 -0
  17. package/skills/a2h-resource-convert/scripts/svg_fidelity_check.js +632 -0
  18. package/skills/a2h-ui-transfer/SKILL.md +431 -0
  19. package/skills/a2h-ui-transfer/references/conversion-procedure.md +547 -0
  20. package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/_/350/214/203/345/274/217/351/200/211/346/213/251/350/257/264/346/230/216.md +1 -1
  21. package/skills/a2h-ui-transfer/references/mvvm-v2/_/350/243/205/351/245/260/345/231/250/351/200/237/346/237/245.md +133 -0
  22. package/skills/{hmos-batch-ui-align/scripts/android_parse_fast.ts → a2h-ui-transfer/scripts/android_parse_fast.js} +532 -301
  23. package/skills/a2h-ui-transfer/scripts/arkts_static_check.js +1624 -0
  24. package/skills/a2h-ui-transfer/scripts/measure_pack.js +1005 -0
  25. package/skills/a2h-ui-transfer/scripts/package.json +3 -0
  26. package/skills/hmos-fix-build-errors/SKILL.md +1 -1
  27. package/skills/hmos-incremental-ui-align/README.md +15 -15
  28. package/skills/hmos-incremental-ui-align/SKILL.md +15 -15
  29. package/skills/hmos-incremental-ui-align/references/State_Model_Template.md +2 -2
  30. package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.js +790 -0
  31. package/skills/hmos-incremental-ui-align/scripts/extract_checklist.js +285 -0
  32. package/skills/hmos-incremental-ui-align/scripts/navigation-capure.md +76 -76
  33. package/skills/hmos-incremental-ui-align/scripts/page_capture.js +756 -0
  34. package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.js +155 -0
  35. package/skills/hmos-integration-test/README.md +341 -0
  36. package/skills/hmos-integration-test/SKILL.md +446 -0
  37. package/skills/hmos-integration-test/scripts/report-tool.mjs +646 -0
  38. package/skills/hmos-integration-test/scripts/resolve-metadata-tool.mjs +147 -0
  39. package/skills/hmos-integration-test/scripts/self-test-runner.mjs +1006 -0
  40. package/skills/hmos-integration-test/scripts/testcases-tool.mjs +189 -0
  41. package/skills/hmos-spec-generate/SKILL.md +26 -24
  42. package/skills/hmos-spec-generate/scripts/parse_requirements.ts +515 -0
  43. package/skills/hmos-spec-generate/template/REQ.txt +22 -0
  44. package/skills/hmos-spec-generate/template/REQ.xlsx +0 -0
  45. package/skills/hmos-batch-ui-align/SKILL.md +0 -141
  46. package/skills/hmos-batch-ui-align/references/conversion-procedure.md +0 -217
  47. package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.ts +0 -999
  48. package/skills/hmos-incremental-ui-align/scripts/extract_checklist.ts +0 -343
  49. package/skills/hmos-incremental-ui-align/scripts/page_capture.ts +0 -977
  50. package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.ts +0 -188
  51. package/skills/hmos-resources-convert/SKILL.md +0 -654
  52. package/skills/hmos-resources-convert/template/AppScope/app.json5 +0 -10
  53. package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +0 -8
  54. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/background.png +0 -0
  55. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/foreground.png +0 -0
  56. package/skills/hmos-resources-convert/template/AppScope/resources/base/media/layered_image.json +0 -7
  57. package/skills/hmos-resources-convert/template/build-profile.json5 +0 -42
  58. package/skills/hmos-resources-convert/template/code-linter.json5 +0 -32
  59. package/skills/hmos-resources-convert/template/entry/build-profile.json5 +0 -33
  60. package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +0 -6
  61. package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +0 -23
  62. package/skills/hmos-resources-convert/template/entry/oh-package.json5 +0 -10
  63. package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +0 -48
  64. package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +0 -16
  65. package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +0 -23
  66. package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +0 -55
  67. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +0 -8
  68. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +0 -8
  69. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +0 -16
  70. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/background.png +0 -0
  71. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/foreground.png +0 -0
  72. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/layered_image.json +0 -7
  73. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/startIcon.png +0 -0
  74. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/backup_config.json +0 -3
  75. package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +0 -5
  76. package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +0 -8
  77. package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +0 -2
  78. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +0 -35
  79. package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +0 -5
  80. package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +0 -16
  81. package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +0 -5
  82. package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +0 -33
  83. package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +0 -23
  84. package/skills/hmos-resources-convert/template/hvigorfile.ts +0 -6
  85. package/skills/hmos-resources-convert/template/oh-package-lock.json5 +0 -28
  86. package/skills/hmos-resources-convert/template/oh-package.json5 +0 -10
  87. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +0 -0
  88. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +0 -0
  89. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +0 -0
  90. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Link/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
  91. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Observed/350/243/205/351/245/260/345/231/250/345/222/214@ObjectLink/350/243/205/351/245/260/345/231/250/357/274/232/345/265/214/345/245/227/347/261/273/345/257/271/350/261/241/345/261/236/346/200/247/345/217/230/345/214/226.md" +0 -0
  92. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Prop/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/215/225/345/220/221/345/220/214/346/255/245.md" +0 -0
  93. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Provide/350/243/205/351/245/260/345/231/250/345/222/214@Consume/350/243/205/351/245/260/345/231/250/357/274/232/344/270/216/345/220/216/344/273/243/347/273/204/344/273/266/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
  94. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@State/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/347/212/266/346/200/201.md" +0 -0
  95. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Track/350/243/205/351/245/260/345/231/250/357/274/232class/345/257/271/350/261/241/345/261/236/346/200/247/347/272/247/346/233/264/346/226/260.md" +0 -0
  96. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Watch/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/346/233/264/346/224/271/351/200/232/347/237/245.md" +0 -0
  97. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/AppStorage/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200/347/232/204UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
  98. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/Environment/357/274/232/350/256/276/345/244/207/347/216/257/345/242/203/346/237/245/350/257/242.md" +0 -0
  99. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/LocalStorage/357/274/232/351/241/265/351/235/242/347/272/247UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
  100. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md" +0 -0
  101. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/PersistentStorage/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
  102. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm//347/256/241/347/220/206/345/272/224/347/224/250/346/213/245/346/234/211/347/232/204/347/212/266/346/200/201/346/246/202/350/277/260.md" +0 -0
  103. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/!!/350/257/255/346/263/225/357/274/232/345/217/214/345/220/221/347/273/221/345/256/232.md" +0 -0
  104. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Computed/350/243/205/351/245/260/345/231/250/357/274/232/350/256/241/347/256/227/345/261/236/346/200/247.md" +0 -0
  105. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Event/350/243/205/351/245/260/345/231/250/357/274/232/350/247/204/350/214/203/347/273/204/344/273/266/350/276/223/345/207/272.md" +0 -0
  106. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Local/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/351/203/250/347/212/266/346/200/201.md" +0 -0
  107. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Monitor/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/344/277/256/346/224/271/345/274/202/346/255/245/347/233/221/345/220/254.md" +0 -0
  108. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@ObservedV2/350/243/205/351/245/260/345/231/250/345/222/214@Trace/350/243/205/351/245/260/345/231/250/357/274/232/347/261/273/345/261/236/346/200/247/345/217/230/345/214/226/350/247/202/346/265/213.md" +0 -0
  109. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Once/350/243/205/351/245/260/345/231/250/357/274/232/345/210/235/345/247/213/345/214/226/345/220/214/346/255/245/344/270/200/346/254/241.md" +0 -0
  110. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Param/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/244/226/351/203/250/350/276/223/345/205/245.md" +0 -0
  111. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Provider/350/243/205/351/245/260/345/231/250/345/222/214@Consumer/350/243/205/351/245/260/345/231/250/357/274/232/350/267/250/347/273/204/344/273/266/345/261/202/347/272/247/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
  112. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Type/350/243/205/351/245/260/345/231/250/357/274/232/346/240/207/350/256/260/347/261/273/345/261/236/346/200/247/347/232/204/347/261/273/345/236/213.md" +0 -0
  113. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/AppStorageV2/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
  114. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/MVVM/346/250/241/345/274/217/357/274/210V2/357/274/211.md" +0 -0
  115. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/PersistenceV2/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
  116. /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2//347/212/266/346/200/201/347/256/241/347/220/206V1/345/220/221V2/350/277/201/347/247/273/344/270/216/346/267/267/347/224/250/346/214/207/345/257/274.md" +0 -0
@@ -0,0 +1,515 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 需求表/需求文本解析工具 —— 把一个需求文件拆成「多条独立需求」,供 hmos-spec-generate
4
+ * skill 逐条生成 spec。一个输入文件包含多条需求,一条需求 = 一份 spec。
5
+ *
6
+ * 支持两种输入类型,拆分后结构完全一致:
7
+ * - Excel 类型 (.xlsx / .xlsm / .csv):一行 = 一条需求
8
+ * 第 1 列 = UI 路径(如「设置-歌词-悬浮窗状态栏歌词」)
9
+ * 第 2 列 = 功能描述(可含换行与缩进的多行文本)
10
+ * 第 3 列及之后的列会按行追加到功能描述末尾,不会被丢弃
11
+ * - 文本类型 (.txt):空行分隔的一段 = 一条需求
12
+ * 段落首行 = UI 路径,其余行 = 功能描述
13
+ * 参见 template/REQ.xlsx 与 template/REQ.txt,两者内容等价。
14
+ *
15
+ * 零第三方依赖:xlsx 本质是 ZIP + XML,这里用 node:zlib 手工解压 + 正则解析。
16
+ * 运行环境:Node ≥ 22.18(原生剥离类型,可直接 `node parse_requirements.ts`)。
17
+ *
18
+ * 用法:
19
+ * node parse_requirements.ts --input REQ.xlsx --json # 逐条需求(推荐,供 skill 迭代)
20
+ * node parse_requirements.ts --input REQ.xlsx # 归一化文本 → stdout
21
+ * node parse_requirements.ts --input REQ.xlsx --out a.txt # 归一化文本 → 文件
22
+ * node parse_requirements.ts --input REQ.xlsx --split-dir ./out # 每条需求一个 .txt
23
+ * node parse_requirements.ts --input REQ.xlsx --sheet 需求 # 指定工作表(名称或 1-based 序号)
24
+ * node parse_requirements.ts --input REQ.xlsx --list-sheets
25
+ * node parse_requirements.ts --input legacy.txt --single # 整个文件当作单条需求
26
+ */
27
+
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import * as zlib from 'node:zlib';
31
+
32
+ type Row = string[];
33
+
34
+ interface RequirementItem {
35
+ /** 1-based 序号,即该需求在输入文件中的出现顺序 */
36
+ index: number;
37
+ /** 供下游命名 spec 文件用的确定性名称(不含扩展名) */
38
+ name: string;
39
+ uiPath: string;
40
+ description: string;
41
+ }
42
+
43
+ interface SheetRef {
44
+ name: string;
45
+ entry: string;
46
+ }
47
+
48
+ interface Options {
49
+ input: string;
50
+ out?: string;
51
+ json: boolean;
52
+ splitDir?: string;
53
+ sheet?: string;
54
+ listSheets: boolean;
55
+ header: 'auto' | 'yes' | 'no';
56
+ single: boolean;
57
+ prefix?: string;
58
+ }
59
+
60
+ // ---------------------------------------------------------------- ZIP 容器
61
+
62
+ function findEndOfCentralDirectory(buf: Buffer): number {
63
+ const start = Math.max(0, buf.length - (22 + 0xffff));
64
+ for (let i = buf.length - 22; i >= start; i--) {
65
+ if (buf.readUInt32LE(i) === 0x06054b50) return i;
66
+ }
67
+ return -1;
68
+ }
69
+
70
+ /** 读出 zip 中所有条目(名称 → 解压后的内容)。仅支持 stored / deflate,够覆盖 xlsx。 */
71
+ function readZipEntries(buf: Buffer): Map<string, Buffer> {
72
+ const eocd = findEndOfCentralDirectory(buf);
73
+ if (eocd < 0) throw new Error('不是合法的 xlsx/zip 文件:找不到 ZIP 中央目录结束记录');
74
+
75
+ let entryCount = buf.readUInt16LE(eocd + 10);
76
+ let cdOffset = buf.readUInt32LE(eocd + 16);
77
+
78
+ // ZIP64:条目数或中央目录偏移溢出时改读 ZIP64 结束记录
79
+ if (entryCount === 0xffff || cdOffset === 0xffffffff) {
80
+ const locator = eocd - 20;
81
+ if (locator >= 0 && buf.readUInt32LE(locator) === 0x07064b50) {
82
+ const z64 = Number(buf.readBigUInt64LE(locator + 8));
83
+ if (z64 >= 0 && z64 + 56 <= buf.length && buf.readUInt32LE(z64) === 0x06064b50) {
84
+ entryCount = Number(buf.readBigUInt64LE(z64 + 32));
85
+ cdOffset = Number(buf.readBigUInt64LE(z64 + 48));
86
+ }
87
+ }
88
+ }
89
+
90
+ const files = new Map<string, Buffer>();
91
+ let p = cdOffset;
92
+ for (let i = 0; i < entryCount; i++) {
93
+ if (p + 46 > buf.length || buf.readUInt32LE(p) !== 0x02014b50) break;
94
+ const method = buf.readUInt16LE(p + 10);
95
+ const compressedSize = buf.readUInt32LE(p + 20);
96
+ const nameLen = buf.readUInt16LE(p + 28);
97
+ const extraLen = buf.readUInt16LE(p + 30);
98
+ const commentLen = buf.readUInt16LE(p + 32);
99
+ const localOffset = buf.readUInt32LE(p + 42);
100
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
101
+ p += 46 + nameLen + extraLen + commentLen;
102
+
103
+ if (localOffset + 30 > buf.length || buf.readUInt32LE(localOffset) !== 0x04034b50) continue;
104
+ const lNameLen = buf.readUInt16LE(localOffset + 26);
105
+ const lExtraLen = buf.readUInt16LE(localOffset + 28);
106
+ const dataStart = localOffset + 30 + lNameLen + lExtraLen;
107
+ const raw = buf.subarray(dataStart, dataStart + compressedSize);
108
+ if (method === 0) files.set(name, Buffer.from(raw));
109
+ else if (method === 8) files.set(name, zlib.inflateRawSync(raw));
110
+ else throw new Error(`xlsx 内部条目 ${name} 使用了不支持的压缩方式 ${method}`);
111
+ }
112
+ return files;
113
+ }
114
+
115
+ // ---------------------------------------------------------------- XML 解析
116
+
117
+ function decodeXmlText(s: string): string {
118
+ return s
119
+ .replace(/_x000D_/g, '')
120
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h: string) => String.fromCodePoint(parseInt(h, 16)))
121
+ .replace(/&#(\d+);/g, (_, d: string) => String.fromCodePoint(Number(d)))
122
+ .replace(/&lt;/g, '<')
123
+ .replace(/&gt;/g, '>')
124
+ .replace(/&quot;/g, '"')
125
+ .replace(/&apos;/g, "'")
126
+ .replace(/&amp;/g, '&');
127
+ }
128
+
129
+ function attr(tag: string, name: string): string | undefined {
130
+ const m = new RegExp(`\\b${name.replace(/:/g, '\\:')}\\s*=\\s*"([^"]*)"`).exec(tag);
131
+ return m ? decodeXmlText(m[1]) : undefined;
132
+ }
133
+
134
+ /** 取出片段内所有 <t> 文本并拼接(富文本多个 run 会被合并;忽略日文注音 <rPh>)。 */
135
+ function collectTextNodes(xml: string): string {
136
+ const cleaned = xml.replace(/<rPh[\s\S]*?<\/rPh>/g, '');
137
+ let text = '';
138
+ const tRe = /<t\b[^>]*\/>|<t\b[^>]*>([\s\S]*?)<\/t>/g;
139
+ let m: RegExpExecArray | null;
140
+ while ((m = tRe.exec(cleaned)) !== null) text += decodeXmlText(m[1] ?? '');
141
+ return text;
142
+ }
143
+
144
+ function parseSharedStrings(xml: string): string[] {
145
+ const out: string[] = [];
146
+ const siRe = /<si\b[^>]*\/>|<si\b[^>]*>([\s\S]*?)<\/si>/g;
147
+ let m: RegExpExecArray | null;
148
+ while ((m = siRe.exec(xml)) !== null) out.push(collectTextNodes(m[1] ?? ''));
149
+ return out;
150
+ }
151
+
152
+ /** "AB12" → 27(0-based 列号) */
153
+ function columnIndexFromRef(ref: string): number {
154
+ const letters = /^([A-Z]+)/.exec(ref.toUpperCase());
155
+ if (!letters) return -1;
156
+ let n = 0;
157
+ for (const ch of letters[1]) n = n * 26 + (ch.charCodeAt(0) - 64);
158
+ return n - 1;
159
+ }
160
+
161
+ function parseSheet(xml: string, shared: string[]): Row[] {
162
+ const rows: Row[] = [];
163
+ const rowRe = /<row\b[^>]*\/>|<row\b[^>]*>([\s\S]*?)<\/row>/g;
164
+ let rm: RegExpExecArray | null;
165
+ while ((rm = rowRe.exec(xml)) !== null) {
166
+ const inner = rm[1] ?? '';
167
+ const cells: Row = [];
168
+ let auto = 0;
169
+ const cellRe = /<c\b([^>]*)\/>|<c\b([^>]*)>([\s\S]*?)<\/c>/g;
170
+ let cm: RegExpExecArray | null;
171
+ while ((cm = cellRe.exec(inner)) !== null) {
172
+ const attrs = cm[1] ?? cm[2] ?? '';
173
+ const body = cm[3] ?? '';
174
+ const ref = attr(attrs, 'r');
175
+ const col = ref ? columnIndexFromRef(ref) : -1;
176
+ const index = col >= 0 ? col : auto;
177
+ auto = index + 1;
178
+
179
+ const type = attr(attrs, 't') ?? 'n';
180
+ let value = '';
181
+ if (type === 'inlineStr') {
182
+ value = collectTextNodes(body);
183
+ } else {
184
+ const v = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(body);
185
+ const rawValue = v ? decodeXmlText(v[1]) : '';
186
+ if (type === 's') {
187
+ const i = Number(rawValue);
188
+ value = Number.isInteger(i) && i >= 0 && i < shared.length ? shared[i] : '';
189
+ } else if (type === 'b') {
190
+ value = rawValue === '1' ? 'TRUE' : rawValue === '0' ? 'FALSE' : rawValue;
191
+ } else {
192
+ value = rawValue;
193
+ }
194
+ }
195
+ while (cells.length < index) cells.push('');
196
+ cells[index] = value;
197
+ }
198
+ rows.push(cells);
199
+ }
200
+ return rows;
201
+ }
202
+
203
+ function resolveSheets(zip: Map<string, Buffer>): SheetRef[] {
204
+ const workbook = zip.get('xl/workbook.xml');
205
+ if (!workbook) throw new Error('xlsx 缺少 xl/workbook.xml,文件可能已损坏');
206
+ const relsBuf = zip.get('xl/_rels/workbook.xml.rels');
207
+ const rels = new Map<string, string>();
208
+ if (relsBuf) {
209
+ const relRe = /<Relationship\b[^>]*\/>|<Relationship\b[^>]*>[\s\S]*?<\/Relationship>/g;
210
+ let m: RegExpExecArray | null;
211
+ const relXml = relsBuf.toString('utf8');
212
+ while ((m = relRe.exec(relXml)) !== null) {
213
+ const id = attr(m[0], 'Id');
214
+ const target = attr(m[0], 'Target');
215
+ if (id && target) rels.set(id, target);
216
+ }
217
+ }
218
+
219
+ const sheets: SheetRef[] = [];
220
+ const sheetRe = /<sheet\b[^>]*\/>|<sheet\b[^>]*>[\s\S]*?<\/sheet>/g;
221
+ let m: RegExpExecArray | null;
222
+ const wbXml = workbook.toString('utf8');
223
+ let fallbackIndex = 0;
224
+ while ((m = sheetRe.exec(wbXml)) !== null) {
225
+ fallbackIndex++;
226
+ const name = attr(m[0], 'name') ?? `Sheet${fallbackIndex}`;
227
+ const rid = attr(m[0], 'r:id') ?? attr(m[0], 'relationshipId');
228
+ let target = rid ? rels.get(rid) : undefined;
229
+ if (!target) target = `worksheets/sheet${fallbackIndex}.xml`;
230
+ const entry = target.startsWith('/') ? target.slice(1) : `xl/${target.replace(/^\.\//, '')}`;
231
+ sheets.push({ name, entry });
232
+ }
233
+ if (sheets.length === 0) throw new Error('xlsx 中没有找到任何工作表');
234
+ return sheets;
235
+ }
236
+
237
+ function readXlsx(file: string, sheetSelector?: string): { rows: Row[]; sheets: SheetRef[]; picked: SheetRef } {
238
+ const zip = readZipEntries(fs.readFileSync(file));
239
+ const sheets = resolveSheets(zip);
240
+
241
+ let picked: SheetRef | undefined;
242
+ if (sheetSelector) {
243
+ const asIndex = Number(sheetSelector);
244
+ if (Number.isInteger(asIndex) && asIndex >= 1 && asIndex <= sheets.length) picked = sheets[asIndex - 1];
245
+ else picked = sheets.find((s) => s.name === sheetSelector);
246
+ if (!picked) {
247
+ throw new Error(
248
+ `找不到工作表 "${sheetSelector}"。可用工作表:${sheets.map((s, i) => `${i + 1}. ${s.name}`).join(',')}`,
249
+ );
250
+ }
251
+ } else {
252
+ picked = sheets[0];
253
+ }
254
+
255
+ const sheetBuf = zip.get(picked.entry);
256
+ if (!sheetBuf) throw new Error(`xlsx 内部缺少工作表数据 ${picked.entry}`);
257
+ const sharedBuf = zip.get('xl/sharedStrings.xml');
258
+ const shared = sharedBuf ? parseSharedStrings(sharedBuf.toString('utf8')) : [];
259
+ return { rows: parseSheet(sheetBuf.toString('utf8'), shared), sheets, picked };
260
+ }
261
+
262
+ // ---------------------------------------------------------------- CSV
263
+
264
+ function parseCsv(text: string): Row[] {
265
+ const body = text.replace(/^/, '');
266
+ const firstLine = body.split(/\r?\n/, 1)[0] ?? '';
267
+ const delimiter = firstLine.includes('\t') ? '\t' : firstLine.includes(';') && !firstLine.includes(',') ? ';' : ',';
268
+
269
+ const rows: Row[] = [];
270
+ let row: Row = [];
271
+ let cell = '';
272
+ let quoted = false;
273
+ for (let i = 0; i < body.length; i++) {
274
+ const ch = body[i];
275
+ if (quoted) {
276
+ if (ch === '"') {
277
+ if (body[i + 1] === '"') { cell += '"'; i++; }
278
+ else quoted = false;
279
+ } else cell += ch;
280
+ continue;
281
+ }
282
+ if (ch === '"') { quoted = true; continue; }
283
+ if (ch === delimiter) { row.push(cell); cell = ''; continue; }
284
+ if (ch === '\r') continue;
285
+ if (ch === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; continue; }
286
+ cell += ch;
287
+ }
288
+ if (cell !== '' || row.length > 0) { row.push(cell); rows.push(row); }
289
+ return rows;
290
+ }
291
+
292
+ // ---------------------------------------------------------------- 拆分为需求条目
293
+
294
+ interface RawItem {
295
+ uiPath: string;
296
+ description: string;
297
+ }
298
+
299
+ function isBlankRow(row: Row): boolean {
300
+ return row.every((c) => (c ?? '').trim() === '');
301
+ }
302
+
303
+ function looksLikeHeader(row: Row): boolean {
304
+ const first = (row[0] ?? '').trim();
305
+ const second = (row[1] ?? '').trim();
306
+ if (!first) return false;
307
+ if (/^(ui\s*路径|ui\s*path|路径|界面路径|菜单路径|设置路径|功能路径)$/i.test(first)) return true;
308
+ return /(路径|path)/i.test(first) && /(描述|说明|功能|需求|desc)/i.test(second);
309
+ }
310
+
311
+ /** 表格:一行 = 一条需求。去掉末尾空白行/空白列后逐行映射。 */
312
+ function rowsToRawItems(rows: Row[], header: Options['header']): RawItem[] {
313
+ const data = rows.filter((r) => !isBlankRow(r));
314
+ if (data.length === 0) return [];
315
+
316
+ // auto 模式下,若跳过表头会把唯一一行数据也吃掉,则宁可不跳——单行需求表比表头更常见
317
+ const autoSkip = header === 'auto' && looksLikeHeader(data[0]) && data.length > 1;
318
+ const skipFirst = header === 'yes' || autoSkip;
319
+ const body = skipFirst ? data.slice(1) : data;
320
+
321
+ return body
322
+ .map((row) => {
323
+ const cells = [...row];
324
+ while (cells.length > 0 && (cells[cells.length - 1] ?? '').trim() === '') cells.pop();
325
+ const uiPath = (cells[0] ?? '').trim();
326
+ const description = cells
327
+ .slice(1)
328
+ .map((c) => (c ?? '').replace(/\s+$/, ''))
329
+ .filter((c) => c.trim() !== '')
330
+ .join('\n')
331
+ .trim();
332
+ return { uiPath, description };
333
+ })
334
+ .filter((item) => item.uiPath !== '' || item.description !== '');
335
+ }
336
+
337
+ /** 文本:空行分隔的一段 = 一条需求,段首行 = UI 路径,其余行 = 功能描述。 */
338
+ function textToRawItems(text: string, single: boolean): RawItem[] {
339
+ const body = text.replace(/^/, '').replace(/\r\n?/g, '\n');
340
+ const blocks = single
341
+ ? [body]
342
+ : body.split(/\n[ \t]*\n+/);
343
+ return blocks
344
+ .map((b) => b.replace(/\s+$/, ''))
345
+ .filter((b) => b.trim() !== '')
346
+ .map((block) => {
347
+ const lines = block.split('\n');
348
+ return {
349
+ uiPath: (lines[0] ?? '').trim(),
350
+ description: lines.slice(1).join('\n').replace(/\s+$/, ''),
351
+ };
352
+ });
353
+ }
354
+
355
+ function sanitizeFileName(name: string): string {
356
+ const cleaned = name.replace(/[\\/:*?"<>|]+/g, '_').replace(/\s+/g, '_').replace(/^_+|_+$/g, '');
357
+ return (cleaned || 'requirement').slice(0, 60);
358
+ }
359
+
360
+ /**
361
+ * 给每条需求编号并生成确定性的输出名:`<前缀>-<序号>-<UI路径>`。
362
+ * 只有一条需求时退化为 `<前缀>`,保持"一个文件一条需求"的旧命名不变。
363
+ */
364
+ function withNames(items: RawItem[], prefix: string): RequirementItem[] {
365
+ const width = String(items.length).length;
366
+ return items.map((item, i) => {
367
+ const index = i + 1;
368
+ const name =
369
+ items.length === 1
370
+ ? sanitizeFileName(prefix)
371
+ : `${sanitizeFileName(prefix)}-${String(index).padStart(width, '0')}-${sanitizeFileName(
372
+ item.uiPath || `requirement_${index}`,
373
+ )}`;
374
+ return { index, name, uiPath: item.uiPath, description: item.description };
375
+ });
376
+ }
377
+
378
+ /** 单条需求归一化为纯文本:UI 路径一行 + 功能描述若干行。 */
379
+ function rawToText(item: RawItem): string {
380
+ return [item.uiPath, item.description].filter((s) => s !== '').join('\n');
381
+ }
382
+
383
+ /** 多条需求合并为纯文本:需求之间空一行(与等价的 .txt 逐字节一致)。 */
384
+ function rawListToText(items: RawItem[]): string {
385
+ return items.map(rawToText).join('\n\n');
386
+ }
387
+
388
+ /** --single:把整份表格压成一条需求(UI 路径留空,描述是全表归一化文本)。 */
389
+ function collapseToSingle(items: RawItem[]): RawItem[] {
390
+ if (items.length <= 1) return items;
391
+ return [{ uiPath: '', description: rawListToText(items) }];
392
+ }
393
+
394
+ /** 整个文件归一化为纯文本,结尾补一个换行。 */
395
+ function toNormalizedText(items: RequirementItem[]): string {
396
+ return items.map(rawToText).join('\n\n') + '\n';
397
+ }
398
+
399
+ // ---------------------------------------------------------------- CLI
400
+
401
+ function parseArgs(argv: string[]): Options {
402
+ const opts: Options = { input: '', json: false, listSheets: false, header: 'auto', single: false };
403
+ for (let i = 0; i < argv.length; i++) {
404
+ const a = argv[i];
405
+ const next = (): string => {
406
+ const v = argv[++i];
407
+ if (v === undefined) throw new Error(`参数 ${a} 缺少取值`);
408
+ return v;
409
+ };
410
+ switch (a) {
411
+ case '--input': case '-i': opts.input = next(); break;
412
+ case '--out': case '-o': opts.out = next(); break;
413
+ case '--json': opts.json = true; break;
414
+ case '--split-dir': opts.splitDir = next(); break;
415
+ case '--sheet': opts.sheet = next(); break;
416
+ case '--list-sheets': opts.listSheets = true; break;
417
+ case '--single': opts.single = true; break;
418
+ case '--prefix': opts.prefix = next(); break;
419
+ case '--header': {
420
+ const v = next();
421
+ if (v !== 'auto' && v !== 'yes' && v !== 'no') throw new Error('--header 只能是 auto | yes | no');
422
+ opts.header = v;
423
+ break;
424
+ }
425
+ case '--help': case '-h':
426
+ console.log(
427
+ [
428
+ '用法: node parse_requirements.ts --input <REQ.xlsx|REQ.csv|REQ.txt> [选项]',
429
+ '',
430
+ '把一个需求文件拆成多条独立需求(一条需求 = 一份 spec)。',
431
+ '',
432
+ ' --input, -i <file> 需求文件(.xlsx / .xlsm / .csv / .txt)',
433
+ ' --json 输出 [{index, name, uiPath, description}](推荐)',
434
+ ' --out, -o <file> 归一化文本写入文件(默认写 stdout)',
435
+ ' --split-dir <dir> 每条需求单独写一个 <name>.txt 到该目录',
436
+ ' --prefix <stem> 覆盖 name 前缀(默认取输入文件名)',
437
+ ' --sheet <name|index> 指定工作表,名称或 1-based 序号(默认第一个)',
438
+ ' --list-sheets 只打印工作表列表',
439
+ ' --header auto|yes|no 表格是否跳过表头行(默认 auto,自动识别)',
440
+ ' --single 整个文件只算一条需求(不按空行/行拆分)',
441
+ ].join('\n'),
442
+ );
443
+ process.exit(0);
444
+ // eslint-disable-next-line no-fallthrough
445
+ default:
446
+ if (!opts.input && !a.startsWith('-')) opts.input = a;
447
+ else throw new Error(`无法识别的参数:${a}`);
448
+ }
449
+ }
450
+ if (!opts.input) throw new Error('缺少必需参数 --input <需求文件>');
451
+ return opts;
452
+ }
453
+
454
+ function main(): void {
455
+ const opts = parseArgs(process.argv.slice(2));
456
+ const input = path.resolve(opts.input);
457
+ if (!fs.existsSync(input)) throw new Error(`输入文件不存在:${input}`);
458
+
459
+ const ext = path.extname(input).toLowerCase();
460
+ if (ext === '.xls') {
461
+ throw new Error('不支持旧版二进制 .xls (BIFF) 格式,请在 Excel/WPS 中另存为 .xlsx 或 .csv 后重试');
462
+ }
463
+
464
+ let raw: RawItem[];
465
+ if (ext === '.txt' || ext === '.md') {
466
+ if (opts.listSheets) { console.log('1\t(纯文本无工作表)'); return; }
467
+ raw = textToRawItems(fs.readFileSync(input, 'utf8'), opts.single);
468
+ } else if (ext === '.csv' || ext === '.tsv') {
469
+ if (opts.listSheets) { console.log('1\t(CSV 无工作表)'); return; }
470
+ const rows = rowsToRawItems(parseCsv(fs.readFileSync(input, 'utf8')), opts.header);
471
+ raw = opts.single ? collapseToSingle(rows) : rows;
472
+ } else {
473
+ const parsed = readXlsx(input, opts.sheet);
474
+ if (opts.listSheets) {
475
+ console.log(parsed.sheets.map((s, i) => `${i + 1}\t${s.name}`).join('\n'));
476
+ return;
477
+ }
478
+ const rows = rowsToRawItems(parsed.rows, opts.header);
479
+ raw = opts.single ? collapseToSingle(rows) : rows;
480
+ }
481
+
482
+ const prefix = opts.prefix ?? path.basename(input, path.extname(input));
483
+ const items = withNames(raw, prefix);
484
+ if (items.length === 0) throw new Error(`需求文件 ${input} 中没有解析到任何需求`);
485
+
486
+ if (opts.splitDir) {
487
+ const dir = path.resolve(opts.splitDir);
488
+ fs.mkdirSync(dir, { recursive: true });
489
+ const written: string[] = [];
490
+ for (const item of items) {
491
+ const file = path.join(dir, `${item.name}.txt`);
492
+ fs.writeFileSync(file, rawToText(item) + '\n', 'utf8');
493
+ written.push(file);
494
+ }
495
+ console.log(written.join('\n'));
496
+ return;
497
+ }
498
+
499
+ const output = opts.json ? JSON.stringify(items, null, 2) + '\n' : toNormalizedText(items);
500
+ if (opts.out) {
501
+ const file = path.resolve(opts.out);
502
+ fs.mkdirSync(path.dirname(file), { recursive: true });
503
+ fs.writeFileSync(file, output, 'utf8');
504
+ console.error(`[parse_requirements] 已解析 ${items.length} 条需求 → ${file}`);
505
+ } else {
506
+ process.stdout.write(output);
507
+ }
508
+ }
509
+
510
+ try {
511
+ main();
512
+ } catch (err) {
513
+ console.error(`[parse_requirements] 错误:${err instanceof Error ? err.message : String(err)}`);
514
+ process.exit(1);
515
+ }
@@ -0,0 +1,22 @@
1
+ 设置-歌词-歌词界面-卡拉OK歌词动画兼容策略(播放页歌词设置同步实现)
2
+ 逐字歌词效果,默认是当前行
3
+ 1,当前行:只为前歌曲的歌词中有单字时间戳的歌词行显示逐字特效
4
+ 2,扩展全部:当前歌曲的歌词中只要有一行歌词带单字时间戳,所有歌词行都显示逐字特效
5
+ 3,总是:当前歌曲所有歌词行都显示逐字特效
6
+
7
+ 设置-用户界面-圆形播放封面(圆形支持旋转)
8
+ 开关,默认关闭,打开后,在播放页以圆形展示封面,并且自动旋转
9
+
10
+ 设置-用户界面-允许不规则封面(支持长方形封面,参考无归)
11
+ 开关,默认打开,播放页封面以大小一致的正方形展示,打开后,在播放页封面以原始样式展示
12
+
13
+ 设置-歌词-悬浮窗状态栏歌词
14
+ 开关 ,默认关闭,打开前需要申请悬浮窗权限,打开后在悬浮窗滚动展示歌词
15
+ 1,左右位置:默认0%
16
+ 2,上下位置:默认0px
17
+ 3,宽度:默认150dp
18
+ 4,大小:默认14.0dp
19
+ 5,选择颜色:默认蓝色
20
+ 6,歌词文本居左对齐:开关 默认关闭,歌词在设置的宽度内中对齐
21
+ 7,状态栏歌词不显示翻译:开关 默认关闭
22
+ 8,在播放界面隐藏:开关 默认关闭 打开后如果切换到播放页,则不在悬浮窗区域显示歌词