growork 1.1.1 → 1.1.2
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.
- package/.claude/settings.local.json +2 -1
- package/README.md +55 -123
- package/claude.md +40 -10
- package/dist/index.js +32 -6
- package/docs/architecture.md +175 -0
- package/docs/developer-notes.md +67 -0
- package/docs/prd-1.1.2.md +136 -0
- package/growork.config.yaml +5 -0
- package/package.json +1 -1
- package/src/commands/sync.ts +36 -7
- package/src/utils/config.ts +5 -2
- package/tests/config.test.ts +13 -2
- package/tests/feishu.test.ts +187 -0
- package/tests/notion.test.ts +81 -0
- package/tests/sync.test.ts +65 -0
- /package/docs/{product/prd-1.0.0.md → prd-1.0.0.md} +0 -0
- /package/docs/{product/prd-1.1.0.md → prd-1.1.1.md} +0 -0
- /package/docs/{product/prd-template.md → prd-template.md} +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# 开发者说
|
|
2
|
+
|
|
3
|
+
## AI 时代的开发范式
|
|
4
|
+
|
|
5
|
+
GroWork 是一个 **零手写代码** 的项目。从第一行代码到现在,所有实现都由 AI 完成,人只负责写文档和提示词。
|
|
6
|
+
|
|
7
|
+
## 核心洞察:后端最贴合 AI
|
|
8
|
+
|
|
9
|
+
后端开发是目前最适合 AI 全流程接管的领域:
|
|
10
|
+
|
|
11
|
+
- **输入输出明确** - API 契约清晰,不涉及主观审美
|
|
12
|
+
- **逻辑可验证** - 单元测试可以客观判断对错
|
|
13
|
+
- **文档驱动** - PRD、API 文档天然适合作为 AI 的上下文
|
|
14
|
+
|
|
15
|
+
相比之下,前端涉及 UI/UX 审美判断,AI 难以完全替代人的决策。
|
|
16
|
+
|
|
17
|
+
## AI All-in-One 闭环
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
PRD 文档 → 架构设计 → 测试用例 → 代码实现 → Code Review → 发布
|
|
21
|
+
↑ │
|
|
22
|
+
└──────────────────── 迭代反馈 ←────────────────────────┘
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
每个环节都由 AI 执行,人只在关键节点做决策。
|
|
26
|
+
|
|
27
|
+
## 多 Tab 工作流
|
|
28
|
+
|
|
29
|
+
将 Claude Code 分为多个独立 Tab,各司其职:
|
|
30
|
+
|
|
31
|
+
| Tab | 职责 |
|
|
32
|
+
| ---------------- | ---------------------------------- |
|
|
33
|
+
| **doc** | 生成/更新文档(PRD、架构、README) |
|
|
34
|
+
| **code** | 编写实现代码 |
|
|
35
|
+
| **test** | 编写和运行测试用例 |
|
|
36
|
+
| **review** | 代码审查,检查规范和潜在问题 |
|
|
37
|
+
| **git** | 提交、推送、PR 管理 |
|
|
38
|
+
|
|
39
|
+
这种分离让每个 Tab 保持专注的上下文,避免单一会话过载。
|
|
40
|
+
|
|
41
|
+
## 人的角色转变
|
|
42
|
+
|
|
43
|
+
在这个模式下,开发者的工作变成了:
|
|
44
|
+
|
|
45
|
+
1. **写文档** - PRD 描述需求,架构文档描述设计
|
|
46
|
+
2. **写提示词** - 精准表达意图,引导 AI 输出
|
|
47
|
+
3. **做决策** - 在多个方案中选择,把控方向
|
|
48
|
+
4. **验收成果** - 确认 AI 的输出符合预期
|
|
49
|
+
|
|
50
|
+
代码不再是手写的,而是「生成」的。
|
|
51
|
+
|
|
52
|
+
## 实践心得
|
|
53
|
+
|
|
54
|
+
1. **文档质量决定代码质量** - 垃圾文档进,垃圾代码出
|
|
55
|
+
2. **小步快跑** - 每次只让 AI 做一件小事,而不是一次性生成大量代码
|
|
56
|
+
3. **测试先行** - 先让 AI 写测试用例,再写实现,AI 更容易做对
|
|
57
|
+
4. **持续 Review** - AI 会犯错,定期审查避免技术债累积
|
|
58
|
+
|
|
59
|
+
## 展望
|
|
60
|
+
|
|
61
|
+
这只是开始。随着 AI 能力提升,未来的开发模式会更加极致:
|
|
62
|
+
|
|
63
|
+
- 需求直接生成可运行的代码
|
|
64
|
+
- 自动修复 bug,自动优化性能
|
|
65
|
+
- 人只需要描述「想要什么」,而不是「怎么做」
|
|
66
|
+
|
|
67
|
+
GroWork 本身就是这个未来的一个小小实验。
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# GroWork 1.1.2 PRD
|
|
2
|
+
|
|
3
|
+
## 概述
|
|
4
|
+
|
|
5
|
+
| 项目 | 内容 |
|
|
6
|
+
| -------- | ----------------------------- |
|
|
7
|
+
| 产品名称 | GroWork |
|
|
8
|
+
| 版本 | 1.1.2 |
|
|
9
|
+
| 状态 | 📝 待开发 |
|
|
10
|
+
| 产品形态 | CLI |
|
|
11
|
+
| 核心改动 | design 字段支持 Figma 链接 |
|
|
12
|
+
|
|
13
|
+
## 背景与目标
|
|
14
|
+
|
|
15
|
+
### 背景
|
|
16
|
+
|
|
17
|
+
当前 design 字段只支持飞书/Notion 文档链接,但设计稿通常在 Figma 中。开发者希望在 design 字段直接配置 Figma 地址,同步时自动生成包含链接的 MD 文件。
|
|
18
|
+
|
|
19
|
+
### 目标
|
|
20
|
+
|
|
21
|
+
1. design 字段支持 Figma URL
|
|
22
|
+
2. Figma URL 直接生成 MD 文件(不下载),内容为链接本身
|
|
23
|
+
3. 保持现有配置结构不变
|
|
24
|
+
|
|
25
|
+
## 功能需求
|
|
26
|
+
|
|
27
|
+
### 配置文件格式
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
# growork.config.yaml(结构不变)
|
|
31
|
+
|
|
32
|
+
versions:
|
|
33
|
+
v1.9:
|
|
34
|
+
QuickCard:
|
|
35
|
+
prd:
|
|
36
|
+
- "https://xxx.feishu.cn/docx/xxx" # 飞书:下载内容
|
|
37
|
+
design:
|
|
38
|
+
- "https://www.figma.com/design/xxx?node-id=1-20" # Figma:生成链接
|
|
39
|
+
- "https://www.figma.com/design/xxx?node-id=1-30"
|
|
40
|
+
api:
|
|
41
|
+
- "https://xxx.feishu.cn/docx/yyy" # 飞书:下载内容
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### URL 类型识别
|
|
45
|
+
|
|
46
|
+
| URL 特征 | 类型 | 处理方式 |
|
|
47
|
+
|---------|------|---------|
|
|
48
|
+
| `feishu.cn` / `larksuite.com` | 飞书 | 调用 API 下载内容 |
|
|
49
|
+
| `notion.so` / `notion.site` | Notion | 调用 API 下载内容 |
|
|
50
|
+
| `figma.com` | Figma | 直接生成链接 MD |
|
|
51
|
+
|
|
52
|
+
### 输出目录结构
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
docs/
|
|
56
|
+
└── v1.9/
|
|
57
|
+
└── QuickCard/
|
|
58
|
+
├── prd/
|
|
59
|
+
│ └── xxx.md # 飞书文档内容
|
|
60
|
+
├── design/
|
|
61
|
+
│ └── design.md # Figma 链接列表
|
|
62
|
+
└── api/
|
|
63
|
+
└── yyy.md # 飞书文档内容
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 生成的 design.md 内容
|
|
67
|
+
|
|
68
|
+
```markdown
|
|
69
|
+
# QuickCard 设计稿
|
|
70
|
+
|
|
71
|
+
- https://www.figma.com/design/xxx?node-id=1-20
|
|
72
|
+
- https://www.figma.com/design/xxx?node-id=1-30
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 技术设计
|
|
76
|
+
|
|
77
|
+
### inferDocType 扩展
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
export function inferDocType(url: string): 'feishu' | 'notion' | 'figma' {
|
|
81
|
+
if (url.includes('feishu.cn') || url.includes('larksuite.com')) {
|
|
82
|
+
return 'feishu';
|
|
83
|
+
}
|
|
84
|
+
if (url.includes('notion.so') || url.includes('notion.site')) {
|
|
85
|
+
return 'notion';
|
|
86
|
+
}
|
|
87
|
+
if (url.includes('figma.com')) {
|
|
88
|
+
return 'figma';
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`无法从 URL 推断文档类型: ${url}`);
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 同步逻辑调整
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// sync.ts
|
|
98
|
+
for (const doc of docs) {
|
|
99
|
+
if (doc.type === 'figma') {
|
|
100
|
+
// Figma:直接写入链接,不调用 API
|
|
101
|
+
// 同一 feature 的多个 Figma URL 合并到一个 design.md
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// 飞书/Notion:调用 API 下载内容
|
|
105
|
+
// ...现有逻辑
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Figma 文档合并
|
|
110
|
+
|
|
111
|
+
同一 feature 下的多个 Figma URL 合并到一个 `design.md` 文件:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
// 按 outputPath 分组 Figma 文档
|
|
115
|
+
const figmaGroups = new Map<string, string[]>();
|
|
116
|
+
for (const doc of figmaDocs) {
|
|
117
|
+
const dir = path.dirname(doc.outputPath);
|
|
118
|
+
const urls = figmaGroups.get(dir) || [];
|
|
119
|
+
urls.push(doc.url);
|
|
120
|
+
figmaGroups.set(dir, urls);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 生成合并后的 design.md
|
|
124
|
+
for (const [dir, urls] of figmaGroups) {
|
|
125
|
+
const content = `# 设计稿\n\n${urls.map(u => `- ${u}`).join('\n')}\n`;
|
|
126
|
+
writeFile(`${dir}/design.md`, content);
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## 验收标准
|
|
131
|
+
|
|
132
|
+
- [ ] `inferDocType` 支持识别 Figma URL
|
|
133
|
+
- [ ] Figma URL 不调用 API,直接生成 MD
|
|
134
|
+
- [ ] 同一 feature 的多个 Figma URL 合并到一个 `design.md`
|
|
135
|
+
- [ ] 飞书/Notion 文档同步逻辑不受影响
|
|
136
|
+
- [ ] `--ver` 和 `-f` 参数对 Figma 同样有效
|
package/growork.config.yaml
CHANGED
|
@@ -39,3 +39,8 @@ versions:
|
|
|
39
39
|
- "https://romangic.sg.larksuite.com/wiki/EB9NwMUP2ipF0Pk0SZBlXOS5gcb"
|
|
40
40
|
test:
|
|
41
41
|
- "https://www.notion.so/2f91190afab580a8acf1daf80ae02ac6"
|
|
42
|
+
v1.9:
|
|
43
|
+
QuickCard:
|
|
44
|
+
design:
|
|
45
|
+
- "https://www.figma.com/design/xxx?node-id=1-20"
|
|
46
|
+
- "https://www.figma.com/design/xxx?node-id=1-30"
|
package/package.json
CHANGED
package/src/commands/sync.ts
CHANGED
|
@@ -28,12 +28,16 @@ export async function syncCommand(options: SyncOptions = {}): Promise<void> {
|
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// 分组:普通文档 vs Figma 文档
|
|
32
|
+
const normalDocs = docs.filter(d => d.type !== 'figma');
|
|
33
|
+
const figmaDocs = docs.filter(d => d.type === 'figma');
|
|
34
|
+
|
|
31
35
|
const feishuService = config.feishu ? new FeishuService(config.feishu) : null;
|
|
32
36
|
const notionService = config.notion ? new NotionService(config.notion) : null;
|
|
33
37
|
|
|
34
|
-
//
|
|
35
|
-
const hasFeishuDocs =
|
|
36
|
-
const hasNotionDocs =
|
|
38
|
+
// 检查凭证(只针对普通文档)
|
|
39
|
+
const hasFeishuDocs = normalDocs.some(d => d.type === 'feishu');
|
|
40
|
+
const hasNotionDocs = normalDocs.some(d => d.type === 'notion');
|
|
37
41
|
|
|
38
42
|
if (hasFeishuDocs && !feishuService) {
|
|
39
43
|
throw new Error('配置文件缺少飞书凭证 (feishu.appId, feishu.appSecret)');
|
|
@@ -46,7 +50,19 @@ export async function syncCommand(options: SyncOptions = {}): Promise<void> {
|
|
|
46
50
|
|
|
47
51
|
let successCount = 0;
|
|
48
52
|
|
|
49
|
-
|
|
53
|
+
// Figma 文档按目录分组
|
|
54
|
+
const figmaGroups = new Map<string, { feature: string; urls: string[] }>();
|
|
55
|
+
for (const doc of figmaDocs) {
|
|
56
|
+
const dir = path.dirname(doc.outputPath);
|
|
57
|
+
const feature = path.basename(path.dirname(dir));
|
|
58
|
+
if (!figmaGroups.has(dir)) {
|
|
59
|
+
figmaGroups.set(dir, { feature, urls: [] });
|
|
60
|
+
}
|
|
61
|
+
figmaGroups.get(dir)!.urls.push(doc.url);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 处理普通文档(feishu/notion)
|
|
65
|
+
for (const doc of normalDocs) {
|
|
50
66
|
const displayName = doc.name || doc.url.slice(-20);
|
|
51
67
|
process.stdout.write(chalk.gray(` ⏳ ${displayName}`));
|
|
52
68
|
|
|
@@ -82,10 +98,23 @@ export async function syncCommand(options: SyncOptions = {}): Promise<void> {
|
|
|
82
98
|
}
|
|
83
99
|
}
|
|
84
100
|
|
|
101
|
+
// 处理 Figma 文档:生成 design.md
|
|
102
|
+
for (const [dir, { feature, urls }] of figmaGroups) {
|
|
103
|
+
const content = `# ${feature} 设计稿\n\n${urls.map(u => `- ${u}`).join('\n')}\n`;
|
|
104
|
+
const outputPath = path.join(process.cwd(), dir, 'design.md');
|
|
105
|
+
|
|
106
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
107
|
+
fs.writeFileSync(outputPath, content, 'utf-8');
|
|
108
|
+
|
|
109
|
+
console.log(chalk.green(` ✓ design.md → ${path.relative(process.cwd(), outputPath)}`));
|
|
110
|
+
successCount++;
|
|
111
|
+
}
|
|
112
|
+
|
|
85
113
|
console.log('');
|
|
86
|
-
|
|
87
|
-
|
|
114
|
+
const totalCount = normalDocs.length + figmaGroups.size;
|
|
115
|
+
if (successCount === totalCount) {
|
|
116
|
+
console.log(chalk.green(`✅ 同步完成,共 ${totalCount} 个文档`));
|
|
88
117
|
} else {
|
|
89
|
-
console.log(chalk.yellow(`⚠️ 同步完成: ${successCount}/${
|
|
118
|
+
console.log(chalk.yellow(`⚠️ 同步完成: ${successCount}/${totalCount} 个文档成功`));
|
|
90
119
|
}
|
|
91
120
|
}
|
package/src/utils/config.ts
CHANGED
|
@@ -35,7 +35,7 @@ export interface GroworkConfigV2 {
|
|
|
35
35
|
export interface NormalizedDoc {
|
|
36
36
|
url: string;
|
|
37
37
|
name?: string;
|
|
38
|
-
type: 'feishu' | 'notion';
|
|
38
|
+
type: 'feishu' | 'notion' | 'figma';
|
|
39
39
|
outputPath: string; // 含 {title} 占位符
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -55,13 +55,16 @@ export function configExists(): boolean {
|
|
|
55
55
|
return fs.existsSync(getConfigPath());
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export function inferDocType(url: string): 'feishu' | 'notion' {
|
|
58
|
+
export function inferDocType(url: string): 'feishu' | 'notion' | 'figma' {
|
|
59
59
|
if (url.includes('feishu.cn') || url.includes('larksuite.com')) {
|
|
60
60
|
return 'feishu';
|
|
61
61
|
}
|
|
62
62
|
if (url.includes('notion.so') || url.includes('notion.site')) {
|
|
63
63
|
return 'notion';
|
|
64
64
|
}
|
|
65
|
+
if (url.includes('figma.com')) {
|
|
66
|
+
return 'figma';
|
|
67
|
+
}
|
|
65
68
|
throw new Error(`无法从 URL 推断文档类型: ${url}`);
|
|
66
69
|
}
|
|
67
70
|
|
package/tests/config.test.ts
CHANGED
|
@@ -58,7 +58,7 @@ interface GroworkConfigV2 {
|
|
|
58
58
|
interface NormalizedDoc {
|
|
59
59
|
url: string;
|
|
60
60
|
name?: string;
|
|
61
|
-
type: 'feishu' | 'notion';
|
|
61
|
+
type: 'feishu' | 'notion' | 'figma';
|
|
62
62
|
outputPath: string;
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -68,13 +68,16 @@ interface SyncOptions {
|
|
|
68
68
|
custom?: boolean;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
function inferDocType(url: string): 'feishu' | 'notion' {
|
|
71
|
+
function inferDocType(url: string): 'feishu' | 'notion' | 'figma' {
|
|
72
72
|
if (url.includes('feishu.cn') || url.includes('larksuite.com')) {
|
|
73
73
|
return 'feishu';
|
|
74
74
|
}
|
|
75
75
|
if (url.includes('notion.so') || url.includes('notion.site')) {
|
|
76
76
|
return 'notion';
|
|
77
77
|
}
|
|
78
|
+
if (url.includes('figma.com')) {
|
|
79
|
+
return 'figma';
|
|
80
|
+
}
|
|
78
81
|
throw new Error(`无法从 URL 推断文档类型: ${url}`);
|
|
79
82
|
}
|
|
80
83
|
|
|
@@ -326,6 +329,14 @@ describe('文档类型推断', () => {
|
|
|
326
329
|
assert.strictEqual(inferDocType('https://myworkspace.notion.site/abc123'), 'notion');
|
|
327
330
|
});
|
|
328
331
|
|
|
332
|
+
it('应识别 Figma URL', () => {
|
|
333
|
+
assert.strictEqual(inferDocType('https://www.figma.com/design/abc123'), 'figma');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('应识别 Figma 文件 URL', () => {
|
|
337
|
+
assert.strictEqual(inferDocType('https://figma.com/file/abc123/My-Design'), 'figma');
|
|
338
|
+
});
|
|
339
|
+
|
|
329
340
|
it('应拒绝未知域名', () => {
|
|
330
341
|
assert.throws(
|
|
331
342
|
() => inferDocType('https://example.com/doc/123'),
|
package/tests/feishu.test.ts
CHANGED
|
@@ -239,6 +239,16 @@ describe('Block 转 Markdown', () => {
|
|
|
239
239
|
return `## ${textElementsToMarkdown(block.heading2?.elements)}\n`;
|
|
240
240
|
case 5: // Heading3
|
|
241
241
|
return `### ${textElementsToMarkdown(block.heading3?.elements)}\n`;
|
|
242
|
+
case 6: // Heading4
|
|
243
|
+
return `#### ${textElementsToMarkdown(block.heading4?.elements)}\n`;
|
|
244
|
+
case 7: // Heading5
|
|
245
|
+
return `##### ${textElementsToMarkdown(block.heading5?.elements)}\n`;
|
|
246
|
+
case 8: // Heading6
|
|
247
|
+
return `###### ${textElementsToMarkdown(block.heading6?.elements)}\n`;
|
|
248
|
+
case 9: // Heading7
|
|
249
|
+
case 10: // Heading8
|
|
250
|
+
case 11: // Heading9
|
|
251
|
+
return `###### ${textElementsToMarkdown(block[`heading${blockType - 6}`]?.elements)}\n`;
|
|
242
252
|
case 12: // Bullet
|
|
243
253
|
return `- ${textElementsToMarkdown(block.bullet?.elements)}`;
|
|
244
254
|
case 13: // Ordered
|
|
@@ -254,6 +264,12 @@ describe('Block 转 Markdown', () => {
|
|
|
254
264
|
return `- [${checked}] ${textElementsToMarkdown(block.todo?.elements)}`;
|
|
255
265
|
case 18: // Divider
|
|
256
266
|
return '---\n';
|
|
267
|
+
case 19: // Image
|
|
268
|
+
const imageToken = block.image?.token;
|
|
269
|
+
return imageToken ? `\n` : '';
|
|
270
|
+
case 30: // Sheet
|
|
271
|
+
const sheetToken = block.sheet?.token;
|
|
272
|
+
return sheetToken ? `[嵌入表格: ${sheetToken}]\n` : '';
|
|
257
273
|
default:
|
|
258
274
|
return null;
|
|
259
275
|
}
|
|
@@ -342,4 +358,175 @@ describe('Block 转 Markdown', () => {
|
|
|
342
358
|
it('应转换分割线', () => {
|
|
343
359
|
assert.strictEqual(blockToMarkdown({ block_type: 18 }), '---\n');
|
|
344
360
|
});
|
|
361
|
+
|
|
362
|
+
it('应转换 Heading4', () => {
|
|
363
|
+
assert.strictEqual(
|
|
364
|
+
blockToMarkdown({ block_type: 6, heading4: { elements: [{ text_run: { content: 'H4' } }] } }),
|
|
365
|
+
'#### H4\n'
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('应转换 Heading5', () => {
|
|
370
|
+
assert.strictEqual(
|
|
371
|
+
blockToMarkdown({ block_type: 7, heading5: { elements: [{ text_run: { content: 'H5' } }] } }),
|
|
372
|
+
'##### H5\n'
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('应转换 Heading6', () => {
|
|
377
|
+
assert.strictEqual(
|
|
378
|
+
blockToMarkdown({ block_type: 8, heading6: { elements: [{ text_run: { content: 'H6' } }] } }),
|
|
379
|
+
'###### H6\n'
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('应转换 Heading7-9 为 H6', () => {
|
|
384
|
+
// Heading7-9 都映射为 ###### (H6)
|
|
385
|
+
assert.strictEqual(
|
|
386
|
+
blockToMarkdown({ block_type: 9, heading3: { elements: [{ text_run: { content: 'H7' } }] } }),
|
|
387
|
+
'###### H7\n'
|
|
388
|
+
);
|
|
389
|
+
assert.strictEqual(
|
|
390
|
+
blockToMarkdown({ block_type: 10, heading4: { elements: [{ text_run: { content: 'H8' } }] } }),
|
|
391
|
+
'###### H8\n'
|
|
392
|
+
);
|
|
393
|
+
assert.strictEqual(
|
|
394
|
+
blockToMarkdown({ block_type: 11, heading5: { elements: [{ text_run: { content: 'H9' } }] } }),
|
|
395
|
+
'###### H9\n'
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('应转换图片块', () => {
|
|
400
|
+
const block = {
|
|
401
|
+
block_type: 19,
|
|
402
|
+
image: { token: 'img_token_123' }
|
|
403
|
+
};
|
|
404
|
+
assert.strictEqual(blockToMarkdown(block), '\n');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('应处理无 token 的图片块', () => {
|
|
408
|
+
const block = { block_type: 19, image: {} };
|
|
409
|
+
assert.strictEqual(blockToMarkdown(block), '');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('应转换嵌入表格', () => {
|
|
413
|
+
const block = {
|
|
414
|
+
block_type: 30,
|
|
415
|
+
sheet: { token: 'sheet_token_456' }
|
|
416
|
+
};
|
|
417
|
+
assert.strictEqual(blockToMarkdown(block), '[嵌入表格: sheet_token_456]\n');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('应处理无 token 的嵌入表格', () => {
|
|
421
|
+
const block = { block_type: 30, sheet: {} };
|
|
422
|
+
assert.strictEqual(blockToMarkdown(block), '');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('应对未知块类型返回 null', () => {
|
|
426
|
+
assert.strictEqual(blockToMarkdown({ block_type: 999 }), null);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('表格转 Markdown', () => {
|
|
431
|
+
function getCellContent(cellBlock: any, blockMap: Map<string, any>): string {
|
|
432
|
+
if (!cellBlock) return '';
|
|
433
|
+
const children = cellBlock.children || [];
|
|
434
|
+
const contents: string[] = [];
|
|
435
|
+
for (const childId of children) {
|
|
436
|
+
const child = blockMap.get(childId);
|
|
437
|
+
if (!child) continue;
|
|
438
|
+
if (child.block_type === 2) {
|
|
439
|
+
contents.push(textElementsToMarkdown(child.text?.elements));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return contents.join(' ').replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function tableToMarkdown(tableBlock: any, blockMap: Map<string, any>): string {
|
|
446
|
+
const table = tableBlock.table;
|
|
447
|
+
if (!table) return '';
|
|
448
|
+
const colSize = table.property?.column_size || 0;
|
|
449
|
+
const rowSize = table.property?.row_size || 0;
|
|
450
|
+
const cellIds = table.cells || [];
|
|
451
|
+
if (colSize === 0 || rowSize === 0) return '';
|
|
452
|
+
|
|
453
|
+
const rows: string[][] = [];
|
|
454
|
+
for (let row = 0; row < rowSize; row++) {
|
|
455
|
+
const rowCells: string[] = [];
|
|
456
|
+
for (let col = 0; col < colSize; col++) {
|
|
457
|
+
const cellIndex = row * colSize + col;
|
|
458
|
+
const cellId = cellIds[cellIndex];
|
|
459
|
+
const cellBlock = blockMap.get(cellId);
|
|
460
|
+
const cellContent = getCellContent(cellBlock, blockMap);
|
|
461
|
+
rowCells.push(cellContent);
|
|
462
|
+
}
|
|
463
|
+
rows.push(rowCells);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const lines: string[] = [];
|
|
467
|
+
if (rows.length > 0) {
|
|
468
|
+
lines.push('| ' + rows[0].join(' | ') + ' |');
|
|
469
|
+
lines.push('| ' + rows[0].map(() => '---').join(' | ') + ' |');
|
|
470
|
+
}
|
|
471
|
+
for (let i = 1; i < rows.length; i++) {
|
|
472
|
+
lines.push('| ' + rows[i].join(' | ') + ' |');
|
|
473
|
+
}
|
|
474
|
+
return lines.join('\n') + '\n';
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
it('应转换简单表格', () => {
|
|
478
|
+
const blockMap = new Map<string, any>();
|
|
479
|
+
// 创建单元格内容块
|
|
480
|
+
blockMap.set('text1', { block_type: 2, text: { elements: [{ text_run: { content: 'Header1' } }] } });
|
|
481
|
+
blockMap.set('text2', { block_type: 2, text: { elements: [{ text_run: { content: 'Header2' } }] } });
|
|
482
|
+
blockMap.set('text3', { block_type: 2, text: { elements: [{ text_run: { content: 'Cell1' } }] } });
|
|
483
|
+
blockMap.set('text4', { block_type: 2, text: { elements: [{ text_run: { content: 'Cell2' } }] } });
|
|
484
|
+
// 创建单元格
|
|
485
|
+
blockMap.set('cell1', { children: ['text1'] });
|
|
486
|
+
blockMap.set('cell2', { children: ['text2'] });
|
|
487
|
+
blockMap.set('cell3', { children: ['text3'] });
|
|
488
|
+
blockMap.set('cell4', { children: ['text4'] });
|
|
489
|
+
|
|
490
|
+
const tableBlock = {
|
|
491
|
+
block_type: 31,
|
|
492
|
+
table: {
|
|
493
|
+
property: { column_size: 2, row_size: 2 },
|
|
494
|
+
cells: ['cell1', 'cell2', 'cell3', 'cell4']
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const result = tableToMarkdown(tableBlock, blockMap);
|
|
499
|
+
assert.strictEqual(result, '| Header1 | Header2 |\n| --- | --- |\n| Cell1 | Cell2 |\n');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('应处理空表格', () => {
|
|
503
|
+
const blockMap = new Map<string, any>();
|
|
504
|
+
const tableBlock = {
|
|
505
|
+
block_type: 31,
|
|
506
|
+
table: { property: { column_size: 0, row_size: 0 }, cells: [] }
|
|
507
|
+
};
|
|
508
|
+
assert.strictEqual(tableToMarkdown(tableBlock, blockMap), '');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('应处理无 table 属性的块', () => {
|
|
512
|
+
const blockMap = new Map<string, any>();
|
|
513
|
+
assert.strictEqual(tableToMarkdown({ block_type: 31 }, blockMap), '');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('应转义单元格中的管道符', () => {
|
|
517
|
+
const blockMap = new Map<string, any>();
|
|
518
|
+
blockMap.set('text1', { block_type: 2, text: { elements: [{ text_run: { content: 'A|B' } }] } });
|
|
519
|
+
blockMap.set('cell1', { children: ['text1'] });
|
|
520
|
+
|
|
521
|
+
const tableBlock = {
|
|
522
|
+
block_type: 31,
|
|
523
|
+
table: {
|
|
524
|
+
property: { column_size: 1, row_size: 1 },
|
|
525
|
+
cells: ['cell1']
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const result = tableToMarkdown(tableBlock, blockMap);
|
|
530
|
+
assert.ok(result.includes('A\\|B'));
|
|
531
|
+
});
|
|
345
532
|
});
|
package/tests/notion.test.ts
CHANGED
|
@@ -102,6 +102,87 @@ describe('Notion URL 解析', () => {
|
|
|
102
102
|
});
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
describe('Notion 页面标题提取', () => {
|
|
106
|
+
function getPageTitle(page: any): string {
|
|
107
|
+
const properties = page.properties;
|
|
108
|
+
if (!properties) return '未命名';
|
|
109
|
+
|
|
110
|
+
for (const key of Object.keys(properties)) {
|
|
111
|
+
const prop = properties[key];
|
|
112
|
+
if (prop.type === 'title' && prop.title?.length > 0) {
|
|
113
|
+
return prop.title.map((t: any) => t.plain_text).join('');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return '未命名';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
it('应提取标准 title 属性', () => {
|
|
121
|
+
const page = {
|
|
122
|
+
properties: {
|
|
123
|
+
Name: {
|
|
124
|
+
type: 'title',
|
|
125
|
+
title: [{ plain_text: 'My Page Title' }]
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
assert.strictEqual(getPageTitle(page), 'My Page Title');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('应合并多个 title 片段', () => {
|
|
133
|
+
const page = {
|
|
134
|
+
properties: {
|
|
135
|
+
Title: {
|
|
136
|
+
type: 'title',
|
|
137
|
+
title: [{ plain_text: 'Hello ' }, { plain_text: 'World' }]
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
assert.strictEqual(getPageTitle(page), 'Hello World');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('应处理空 properties', () => {
|
|
145
|
+
const page = { properties: {} };
|
|
146
|
+
assert.strictEqual(getPageTitle(page), '未命名');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('应处理无 properties 的页面', () => {
|
|
150
|
+
const page = {};
|
|
151
|
+
assert.strictEqual(getPageTitle(page), '未命名');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('应处理空 title 数组', () => {
|
|
155
|
+
const page = {
|
|
156
|
+
properties: {
|
|
157
|
+
Name: { type: 'title', title: [] }
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
assert.strictEqual(getPageTitle(page), '未命名');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('应跳过非 title 类型属性', () => {
|
|
164
|
+
const page = {
|
|
165
|
+
properties: {
|
|
166
|
+
Status: { type: 'select', select: { name: 'Done' } },
|
|
167
|
+
Name: { type: 'title', title: [{ plain_text: 'Actual Title' }] }
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
assert.strictEqual(getPageTitle(page), 'Actual Title');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('应处理自定义属性名的 title', () => {
|
|
174
|
+
const page = {
|
|
175
|
+
properties: {
|
|
176
|
+
'页面标题': {
|
|
177
|
+
type: 'title',
|
|
178
|
+
title: [{ plain_text: '中文标题' }]
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
assert.strictEqual(getPageTitle(page), '中文标题');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
105
186
|
describe('Notion 属性值提取', () => {
|
|
106
187
|
describe('title 属性', () => {
|
|
107
188
|
it('应提取 title 文本', () => {
|