growork 1.0.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.
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import chalk from 'chalk';
4
- import { loadConfig } from '../utils/config.js';
4
+ import { loadConfigV2, normalizeConfig, sanitizeFileName, SyncOptions, NormalizedDoc } from '../utils/config.js';
5
5
  import { FeishuService } from '../services/feishu.js';
6
6
  import { NotionService } from '../services/notion.js';
7
7
 
@@ -14,44 +14,74 @@ function clearLine(): void {
14
14
  }
15
15
  }
16
16
 
17
- export async function syncCommand(docName?: string): Promise<void> {
18
- const config = loadConfig();
17
+ function extractTitleFromMarkdown(content: string): string {
18
+ const match = content.match(/^#\s+(.+)$/m);
19
+ return match ? match[1].trim() : '未命名文档';
20
+ }
21
+
22
+ export async function syncCommand(options: SyncOptions = {}): Promise<void> {
23
+ const config = loadConfigV2();
24
+ const docs = normalizeConfig(config, options);
25
+
26
+ if (docs.length === 0) {
27
+ console.log(chalk.yellow('⚠️ 没有找到匹配的文档'));
28
+ return;
29
+ }
30
+
31
+ // 分组:普通文档 vs Figma 文档
32
+ const normalDocs = docs.filter(d => d.type !== 'figma');
33
+ const figmaDocs = docs.filter(d => d.type === 'figma');
34
+
19
35
  const feishuService = config.feishu ? new FeishuService(config.feishu) : null;
20
36
  const notionService = config.notion ? new NotionService(config.notion) : null;
21
37
 
22
- let docsToSync = config.docs;
38
+ // 检查凭证(只针对普通文档)
39
+ const hasFeishuDocs = normalDocs.some(d => d.type === 'feishu');
40
+ const hasNotionDocs = normalDocs.some(d => d.type === 'notion');
23
41
 
24
- if (docName) {
25
- const doc = config.docs.find(d => d.name === docName);
26
- if (!doc) {
27
- console.log(chalk.red(`❌ 未找到名为 "${docName}" 的文档配置`));
28
- console.log(chalk.gray(`可用的文档: ${config.docs.map(d => d.name).join(', ')}`));
29
- process.exit(1);
30
- }
31
- docsToSync = [doc];
42
+ if (hasFeishuDocs && !feishuService) {
43
+ throw new Error('配置文件缺少飞书凭证 (feishu.appId, feishu.appSecret)');
44
+ }
45
+ if (hasNotionDocs && !notionService) {
46
+ throw new Error('配置文件缺少 Notion 凭证 (notion.token)');
32
47
  }
33
48
 
34
49
  console.log(chalk.blue('📄 开始同步文档...\n'));
35
50
 
36
51
  let successCount = 0;
37
52
 
38
- for (const doc of docsToSync) {
39
- process.stdout.write(chalk.gray(` ${doc.name.padEnd(15)} ${doc.output}`));
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) {
66
+ const displayName = doc.name || doc.url.slice(-20);
67
+ process.stdout.write(chalk.gray(` ⏳ ${displayName}`));
40
68
 
41
69
  try {
42
70
  let content: string;
43
71
  if (doc.type === 'feishu') {
44
- if (!feishuService) throw new Error('飞书服务未配置');
45
- content = await feishuService.getDocumentAsMarkdown(doc.url);
46
- } else if (doc.type === 'notion') {
47
- if (!notionService) throw new Error('Notion 服务未配置');
48
- content = await notionService.getPageAsMarkdown(doc.url);
72
+ content = await feishuService!.getDocumentAsMarkdown(doc.url);
49
73
  } else {
50
- throw new Error(`不支持的文档类型: ${doc.type}`);
74
+ content = await notionService!.getPageAsMarkdown(doc.url);
51
75
  }
52
76
 
53
- const outputPath = path.join(process.cwd(), doc.output);
77
+ // markdown 提取标题
78
+ const title = doc.name || extractTitleFromMarkdown(content);
79
+ const safeTitle = sanitizeFileName(title);
80
+
81
+ // 替换占位符生成最终路径
82
+ const outputPath = path.join(process.cwd(), doc.outputPath.replace('{title}', safeTitle));
54
83
  const outputDir = path.dirname(outputPath);
84
+
55
85
  if (!fs.existsSync(outputDir)) {
56
86
  fs.mkdirSync(outputDir, { recursive: true });
57
87
  }
@@ -59,19 +89,32 @@ export async function syncCommand(docName?: string): Promise<void> {
59
89
  fs.writeFileSync(outputPath, content, 'utf-8');
60
90
 
61
91
  clearLine();
62
- console.log(chalk.green(` ✓ ${doc.name.padEnd(15)} → ${doc.output}`));
92
+ console.log(chalk.green(` ✓ ${safeTitle.padEnd(25)} → ${path.relative(process.cwd(), outputPath)}`));
63
93
  successCount++;
64
94
  } catch (error) {
65
95
  clearLine();
66
96
  const errorMessage = error instanceof Error ? error.message : String(error);
67
- console.log(chalk.red(` ✗ ${doc.name.padEnd(15)} → ${errorMessage}`));
97
+ console.log(chalk.red(` ✗ ${displayName.padEnd(25)} → ${errorMessage}`));
68
98
  }
69
99
  }
70
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
+
71
113
  console.log('');
72
- if (successCount === docsToSync.length) {
73
- console.log(chalk.green(`✅ 同步完成,共 ${docsToSync.length} 个文档`));
114
+ const totalCount = normalDocs.length + figmaGroups.size;
115
+ if (successCount === totalCount) {
116
+ console.log(chalk.green(`✅ 同步完成,共 ${totalCount} 个文档`));
74
117
  } else {
75
- console.log(chalk.yellow(`⚠️ 同步完成: ${successCount}/${docsToSync.length} 个文档成功`));
118
+ console.log(chalk.yellow(`⚠️ 同步完成: ${successCount}/${totalCount} 个文档成功`));
76
119
  }
77
120
  }
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ const program = new Command();
10
10
  program
11
11
  .name('growork')
12
12
  .description('将飞书文档同步到本地,为 AI Agent 提供完整上下文')
13
- .version('1.0.0');
13
+ .version('2.0.0');
14
14
 
15
15
  program
16
16
  .command('init')
@@ -18,9 +18,18 @@ program
18
18
  .action(initCommand);
19
19
 
20
20
  program
21
- .command('sync [name]')
22
- .description('同步文档,可选指定文档名称')
23
- .action(syncCommand);
21
+ .command('sync')
22
+ .description('同步文档')
23
+ .option('--ver <version>', '只同步指定版本')
24
+ .option('-f, --feature <feature>', '只同步指定 feature')
25
+ .option('-c, --custom', '只同步全局文档')
26
+ .action((options) => {
27
+ // 将 ver 映射到 version
28
+ if (options.ver) {
29
+ options.version = options.ver;
30
+ }
31
+ syncCommand(options);
32
+ });
24
33
 
25
34
  program
26
35
  .command('list')
@@ -2,13 +2,6 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import * as yaml from 'yaml';
4
4
 
5
- export interface DocConfig {
6
- name: string;
7
- type: 'feishu' | 'notion';
8
- url: string;
9
- output: string;
10
- }
11
-
12
5
  export interface NotionConfig {
13
6
  token: string;
14
7
  }
@@ -16,13 +9,40 @@ export interface NotionConfig {
16
9
  export interface FeishuConfig {
17
10
  appId: string;
18
11
  appSecret: string;
19
- domain?: 'feishu' | 'lark'; // 默认 feishu,国际版用 lark
12
+ domain?: 'feishu' | 'lark';
20
13
  }
14
+ export type DocInput = string | { url: string; name?: string };
15
+
16
+ export type FeatureValue = DocInput[] | {
17
+ prd?: DocInput[];
18
+ design?: DocInput[];
19
+ api?: DocInput[];
20
+ test?: DocInput[];
21
+ };
21
22
 
22
- export interface GroworkConfig {
23
+ export interface GroworkConfigV2 {
23
24
  feishu?: FeishuConfig;
24
25
  notion?: NotionConfig;
25
- docs: DocConfig[];
26
+ outputDir?: string; // 输出根目录,默认 "docs"
27
+ custom?: DocInput[];
28
+ versions?: {
29
+ [version: string]: {
30
+ [feature: string]: FeatureValue;
31
+ };
32
+ };
33
+ }
34
+
35
+ export interface NormalizedDoc {
36
+ url: string;
37
+ name?: string;
38
+ type: 'feishu' | 'notion' | 'figma';
39
+ outputPath: string; // 含 {title} 占位符
40
+ }
41
+
42
+ export interface SyncOptions {
43
+ version?: string;
44
+ feature?: string;
45
+ custom?: boolean;
26
46
  }
27
47
 
28
48
  const CONFIG_FILE_NAME = 'growork.config.yaml';
@@ -35,38 +55,125 @@ export function configExists(): boolean {
35
55
  return fs.existsSync(getConfigPath());
36
56
  }
37
57
 
38
- export function loadConfig(): GroworkConfig {
39
- const configPath = getConfigPath();
58
+ export function inferDocType(url: string): 'feishu' | 'notion' | 'figma' {
59
+ if (url.includes('feishu.cn') || url.includes('larksuite.com')) {
60
+ return 'feishu';
61
+ }
62
+ if (url.includes('notion.so') || url.includes('notion.site')) {
63
+ return 'notion';
64
+ }
65
+ if (url.includes('figma.com')) {
66
+ return 'figma';
67
+ }
68
+ throw new Error(`无法从 URL 推断文档类型: ${url}`);
69
+ }
40
70
 
41
- if (!fs.existsSync(configPath)) {
42
- throw new Error(`配置文件不存在: ${configPath}\n请先运行 growork init 初始化配置`);
71
+ export function sanitizeFileName(title: string): string {
72
+ return title
73
+ .replace(/[\/\\:*?"<>|]/g, '') // 移除文件系统保留字符
74
+ .replace(/\s+/g, '-') // 空格替换为 -
75
+ .replace(/-+/g, '-') // 连续 - 合并
76
+ .replace(/^-|-$/g, ''); // 首尾 - 去除
77
+ }
78
+
79
+ export function parseDocInput(input: DocInput): { url: string; name?: string } {
80
+ if (typeof input === 'string') {
81
+ return { url: input };
43
82
  }
83
+ return input;
84
+ }
44
85
 
45
- const content = fs.readFileSync(configPath, 'utf-8');
46
- const config = yaml.parse(content) as GroworkConfig;
86
+ function isTypedFeature(value: FeatureValue): value is { prd?: DocInput[]; design?: DocInput[]; api?: DocInput[]; test?: DocInput[] } {
87
+ return !Array.isArray(value);
88
+ }
89
+
90
+ export function normalizeConfig(config: GroworkConfigV2, options: SyncOptions = {}): NormalizedDoc[] {
91
+ const docs: NormalizedDoc[] = [];
92
+ const outputDir = config.outputDir || 'docs';
93
+
94
+ // 处理 custom 文档
95
+ if (config.custom && (options.custom || (!options.version && !options.feature))) {
96
+ for (const input of config.custom) {
97
+ const { url, name } = parseDocInput(input);
98
+ docs.push({
99
+ url,
100
+ name,
101
+ type: inferDocType(url),
102
+ outputPath: `${outputDir}/custom/{title}.md`,
103
+ });
104
+ }
105
+ }
106
+
107
+ // 如果只请求 custom,直接返回
108
+ if (options.custom) {
109
+ return docs;
110
+ }
47
111
 
48
- // 验证配置
49
- if (!config.docs || config.docs.length === 0) {
50
- throw new Error('配置文件中没有配置任何文档');
112
+ // 处理 versions
113
+ if (config.versions) {
114
+ for (const [version, features] of Object.entries(config.versions)) {
115
+ // 版本过滤
116
+ if (options.version && options.version !== version) continue;
117
+
118
+ for (const [feature, value] of Object.entries(features)) {
119
+ // feature 过滤
120
+ if (options.feature && options.feature !== feature) continue;
121
+
122
+ if (isTypedFeature(value)) {
123
+ // 分类型的 feature
124
+ for (const docType of ['prd', 'design', 'api', 'test'] as const) {
125
+ const docInputs = value[docType];
126
+ if (!docInputs) continue;
127
+
128
+ for (const input of docInputs) {
129
+ const { url, name } = parseDocInput(input);
130
+ docs.push({
131
+ url,
132
+ name,
133
+ type: inferDocType(url),
134
+ outputPath: `${outputDir}/${version}/${feature}/${docType}/{title}.md`,
135
+ });
136
+ }
137
+ }
138
+ } else {
139
+ // 简单 feature(数组形式)
140
+ for (const input of value) {
141
+ const { url, name } = parseDocInput(input);
142
+ docs.push({
143
+ url,
144
+ name,
145
+ type: inferDocType(url),
146
+ outputPath: `${outputDir}/${version}/${feature}/{title}.md`,
147
+ });
148
+ }
149
+ }
150
+ }
151
+ }
51
152
  }
52
153
 
53
- // 检查文档类型所需的凭证
54
- const hasFeishuDocs = config.docs.some(d => d.type === 'feishu');
55
- const hasNotionDocs = config.docs.some(d => d.type === 'notion');
154
+ return docs;
155
+ }
156
+
157
+ export function loadConfigV2(): GroworkConfigV2 {
158
+ const configPath = getConfigPath();
56
159
 
57
- if (hasFeishuDocs && (!config.feishu?.appId || !config.feishu?.appSecret)) {
58
- throw new Error('配置文件缺少飞书凭证 (feishu.appId, feishu.appSecret)');
160
+ if (!fs.existsSync(configPath)) {
161
+ throw new Error(`配置文件不存在: ${configPath}\n请先运行 growork init 初始化配置`);
59
162
  }
60
163
 
61
- if (hasNotionDocs && !config.notion?.token) {
62
- throw new Error('配置文件缺少 Notion 凭证 (notion.token)');
164
+ const content = fs.readFileSync(configPath, 'utf-8');
165
+ const config = yaml.parse(content) as GroworkConfigV2;
166
+
167
+ // v2.0 至少需要 custom 或 versions
168
+ if (!config.custom && !config.versions) {
169
+ throw new Error('配置文件中没有配置任何文档(custom 或 versions)');
63
170
  }
64
171
 
65
172
  return config;
66
173
  }
67
174
 
68
175
  export function getDefaultConfig(): string {
69
- return `# Growork 配置文件
176
+ return `# Growork v2.0 配置文件
70
177
 
71
178
  # 飞书应用凭证 (使用飞书文档时需要)
72
179
  feishu:
@@ -78,18 +185,30 @@ feishu:
78
185
  notion:
79
186
  token: "ntn_xxxx" # Notion Integration Token
80
187
 
81
- # 文档同步配置
82
- docs:
83
- # 飞书文档示例
84
- - name: prd
85
- type: feishu
86
- url: "https://xxx.feishu.cn/docx/xxxxx"
87
- output: "docs/product/prd.md"
88
-
89
- # Notion 文档示例
90
- - name: notion-prd
91
- type: notion
92
- url: "https://www.notion.so/xxxxx"
93
- output: "docs/product/notion-prd.md"
188
+ # 输出根目录(默认 "docs")
189
+ outputDir: "docs"
190
+
191
+ # 全局文档(不跟版本)
192
+ custom:
193
+ - "https://xxx.feishu.cn/docx/xxxxx" # 最简写法
194
+ # - url: "https://www.notion.so/xxxxx"
195
+ # name: "技术架构" # 可选:自定义名称
196
+
197
+ # 版本化文档
198
+ versions:
199
+ v1.0:
200
+ 用户登录:
201
+ prd:
202
+ - "https://xxx.feishu.cn/docx/xxxxx"
203
+ # design:
204
+ # - "https://xxx.feishu.cn/docx/yyyyy"
205
+ # api:
206
+ # - "https://xxx.feishu.cn/docx/zzzzz"
207
+ # test:
208
+ # - "https://xxx.feishu.cn/docx/aaaaa"
209
+
210
+ # 简单 feature 可不分类
211
+ # 小优化:
212
+ # - "https://xxx.feishu.cn/docx/bbbbb"
94
213
  `;
95
214
  }