workflow-cli 0.1.0__py3-none-any.whl

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.
wfcli/core/docgen.py ADDED
@@ -0,0 +1,219 @@
1
+ """文档生成引擎
2
+
3
+ 基于模板结构生成交互式文档,收集用户输入后填充模板。
4
+ """
5
+
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+ from wfcli.core.template import load_template, list_templates
13
+ from wfcli.core.naming import build_filename, check_duplicate
14
+ from wfcli.utils.fs import read_template
15
+
16
+
17
+ def select_template(workspace_root: Path) -> dict:
18
+ """交互式选择模板
19
+
20
+ Returns:
21
+ 选中的模板信息 {"name", "path", "type"}
22
+ """
23
+ templates = list_templates(workspace_root)
24
+ if not templates:
25
+ raise click.ClickException("当前工作空间没有可用模板,请先检查工作空间是否完整。")
26
+
27
+ click.echo("\n可用文档类型:")
28
+ for i, t in enumerate(templates, 1):
29
+ click.echo(f" [{i}] {t['type']} ({t['name']})")
30
+
31
+ choice = click.prompt(
32
+ "\n请选择文档类型",
33
+ type=click.IntRange(1, len(templates)),
34
+ )
35
+ return templates[choice - 1]
36
+
37
+
38
+ def collect_inputs(template_data: dict) -> dict[str, str]:
39
+ """根据模板的 AI 执行指令,交互式收集用户输入
40
+
41
+ Args:
42
+ template_data: load_template() 的返回值
43
+
44
+ Returns:
45
+ {指令描述: 用户输入} 的字典
46
+ """
47
+ instructions = template_data.get("instructions", [])
48
+ if not instructions:
49
+ return {}
50
+
51
+ click.echo("\n请按提示填写文档内容:")
52
+ click.echo("─" * 40)
53
+
54
+ inputs = {}
55
+ for i, instruction in enumerate(instructions, 1):
56
+ # 将指令转为简短的提示
57
+ prompt_text = instruction[:80] + "..." if len(instruction) > 80 else instruction
58
+ value = click.prompt(f"\n[{i}] {prompt_text}", default="", show_default=False)
59
+ inputs[instruction] = value
60
+
61
+ return inputs
62
+
63
+
64
+ def generate_document(
65
+ template_data: dict,
66
+ inputs: dict[str, str],
67
+ title: str = "",
68
+ date: datetime | None = None,
69
+ ) -> str:
70
+ """根据模板和用户输入生成文档内容
71
+
72
+ Args:
73
+ template_data: load_template() 的返回值
74
+ inputs: collect_inputs() 的返回值
75
+ title: 文档标题
76
+ date: 文档日期
77
+
78
+ Returns:
79
+ 生成的 Markdown 文档内容
80
+ """
81
+ date = date or datetime.now()
82
+ date_str = date.strftime("%Y-%m-%d")
83
+
84
+ lines = []
85
+
86
+ # 文档标题
87
+ if title:
88
+ lines.append(f"# {title}\n")
89
+
90
+ # 元信息
91
+ lines.append(f"> 生成日期:{date_str}")
92
+ lines.append(f"> 模板:{template_data.get('name', '')}\n")
93
+
94
+ # 按模板骨架生成内容
95
+ sections = template_data.get("sections", {})
96
+ structure = template_data.get("structure", [])
97
+
98
+ for heading in structure:
99
+ level = len(heading) - len(heading.lstrip("#"))
100
+ heading_text = heading.lstrip("# ").strip()
101
+
102
+ if level == 1:
103
+ continue # 跳过一级标题(已添加)
104
+
105
+ lines.append(f"\n{heading}")
106
+
107
+ # 如果该章节有用户输入,填充输入内容
108
+ section_content = sections.get(heading_text, "")
109
+ matched_input = _find_matching_input(heading_text, inputs)
110
+
111
+ if matched_input:
112
+ lines.append(f"\n{matched_input}")
113
+ elif section_content:
114
+ # 保留模板中的指导性内容(去掉占位符)
115
+ cleaned = _clean_placeholder(section_content)
116
+ if cleaned:
117
+ lines.append(f"\n{cleaned}")
118
+
119
+ return "\n".join(lines)
120
+
121
+
122
+ def save_document(
123
+ content: str,
124
+ workspace_root: Path,
125
+ target_dir: str,
126
+ filename: str,
127
+ ) -> Path:
128
+ """保存生成的文档到本地目录
129
+
130
+ Args:
131
+ content: 文档内容
132
+ workspace_root: 工作空间根目录
133
+ target_dir: 目标目录名(相对于工作空间根目录)
134
+ filename: 文件名
135
+
136
+ Returns:
137
+ 保存的文件路径
138
+ """
139
+ target_path = workspace_root / target_dir
140
+ target_path.mkdir(parents=True, exist_ok=True)
141
+
142
+ file_path = target_path / filename
143
+ file_path.write_text(content, encoding="utf-8")
144
+ return file_path
145
+
146
+
147
+ def check_and_save(
148
+ content: str,
149
+ workspace_root: Path,
150
+ target_dir: str,
151
+ topic: str,
152
+ ext: str = "md",
153
+ ) -> Path:
154
+ """检查重复并保存文档
155
+
156
+ 如果存在疑似重复文件,提示用户选择覆盖或新建。
157
+ """
158
+ target_path = workspace_root / target_dir
159
+ duplicates = check_duplicate(target_path, topic, ext)
160
+
161
+ if duplicates:
162
+ click.echo(f"\n发现疑似重复文件:")
163
+ for i, f in enumerate(duplicates, 1):
164
+ click.echo(f" [{i}] {f.name}")
165
+ click.echo(f" [0] 新建文件")
166
+
167
+ choice = click.prompt("请选择处理方式", type=click.IntRange(0, len(duplicates)), default=0)
168
+
169
+ if choice > 0:
170
+ # 覆盖已有文件
171
+ existing = duplicates[choice - 1]
172
+ existing.write_text(content, encoding="utf-8")
173
+ return existing
174
+
175
+ # 新建文件
176
+ filename = build_filename(topic, ext)
177
+ return save_document(content, workspace_root, target_dir, filename)
178
+
179
+
180
+ def run_checklist(template_data: dict) -> bool:
181
+ """运行自检清单,返回是否全部通过"""
182
+ checklist = template_data.get("checklist", [])
183
+ if not checklist:
184
+ return True
185
+
186
+ click.echo("\n自检清单:")
187
+ all_passed = True
188
+ for item in checklist:
189
+ result = click.confirm(f" [ ] {item}", default=True)
190
+ if not result:
191
+ all_passed = False
192
+
193
+ return all_passed
194
+
195
+
196
+ # ── 内部工具函数 ──────────────────────────────────────────────────
197
+
198
+ def _find_matching_input(heading: str, inputs: dict) -> str:
199
+ """根据标题找到对应的用户输入"""
200
+ heading_lower = heading.lower()
201
+ for instruction, value in inputs.items():
202
+ instr_lower = instruction.lower()
203
+ if heading_lower in instr_lower or instr_lower in heading_lower:
204
+ return value
205
+ return ""
206
+
207
+
208
+ def _clean_placeholder(content: str) -> str:
209
+ """清理模板中的占位符内容"""
210
+ lines = []
211
+ for line in content.split("\n"):
212
+ stripped = line.strip()
213
+ # 跳过明显的占位符
214
+ if stripped in ("...", "[待填写]", "[占位]", "TODO"):
215
+ continue
216
+ if stripped.startswith("<!--") and stripped.endswith("-->"):
217
+ continue
218
+ lines.append(line)
219
+ return "\n".join(lines).strip()
wfcli/core/naming.py ADDED
@@ -0,0 +1,125 @@
1
+ """文件命名规范工具
2
+
3
+ 命名格式: {日期}_{主题描述}.{扩展名}
4
+ 日期格式: YYYY-MM-DD
5
+ 示例: 2026-06-24_季度经营分析会.md
6
+ """
7
+
8
+ import re
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+
12
+
13
+ # 标准日期格式
14
+ DATE_FORMAT = "%Y-%m-%d"
15
+ DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}_")
16
+
17
+
18
+ def build_filename(
19
+ topic: str,
20
+ ext: str = "md",
21
+ date: datetime | None = None,
22
+ ) -> str:
23
+ """生成标准文件名
24
+
25
+ Args:
26
+ topic: 主题描述
27
+ ext: 文件扩展名,默认 md
28
+ date: 日期,默认为今天
29
+
30
+ Returns:
31
+ 格式化的文件名,如 "2026-06-24_季度经营分析会.md"
32
+ """
33
+ date = date or datetime.now()
34
+ date_str = date.strftime(DATE_FORMAT)
35
+ # 清理主题中的非法字符
36
+ topic = re.sub(r'[\\/:*?"<>|]', "_", topic).strip()
37
+ return f"{date_str}_{topic}.{ext}"
38
+
39
+
40
+ def parse_filename(filename: str) -> dict:
41
+ """解析文件名,提取日期和主题
42
+
43
+ Returns:
44
+ {"date": str, "topic": str, "ext": str, "valid": bool}
45
+ """
46
+ path = Path(filename)
47
+ name = path.stem
48
+ ext = path.suffix.lstrip(".")
49
+
50
+ result = {"date": "", "topic": name, "ext": ext, "valid": False}
51
+
52
+ if DATE_PATTERN.match(name):
53
+ date_str = name[:10]
54
+ topic = name[11:] # 跳过日期和下划线
55
+ result["date"] = date_str
56
+ result["topic"] = topic
57
+ result["valid"] = True
58
+ else:
59
+ result["topic"] = name
60
+
61
+ return result
62
+
63
+
64
+ def validate_filename(filename: str) -> tuple[bool, str]:
65
+ """校验文件名是否符合命名规范
66
+
67
+ Returns:
68
+ (是否合规, 不合规原因)
69
+ """
70
+ info = parse_filename(filename)
71
+ if not info["valid"]:
72
+ return False, f"文件名不符合规范(应为 YYYY-MM-DD_主题.扩展名): {filename}"
73
+
74
+ # 校验日期是否合法
75
+ try:
76
+ datetime.strptime(info["date"], DATE_FORMAT)
77
+ except ValueError:
78
+ return False, f"日期不合法: {info['date']}"
79
+
80
+ # 校验主题是否为空
81
+ if not info["topic"].strip():
82
+ return False, "主题描述不能为空"
83
+
84
+ return True, ""
85
+
86
+
87
+ def check_duplicate(directory: Path, topic: str, ext: str = "md") -> list[Path]:
88
+ """检查目录下是否存在主题相近的文件
89
+
90
+ Args:
91
+ directory: 目标目录
92
+ topic: 主题关键词
93
+ ext: 扩展名
94
+
95
+ Returns:
96
+ 疑似重复的文件列表
97
+ """
98
+ if not directory.exists():
99
+ return []
100
+
101
+ duplicates = []
102
+ topic_lower = topic.lower()
103
+ for f in directory.glob(f"*.{ext}"):
104
+ info = parse_filename(f.name)
105
+ if topic_lower in info["topic"].lower():
106
+ duplicates.append(f)
107
+
108
+ return sorted(duplicates, key=lambda p: p.name, reverse=True)
109
+
110
+
111
+ def get_week_number(date: datetime | None = None) -> int:
112
+ """获取 ISO 周数"""
113
+ date = date or datetime.now()
114
+ return date.isocalendar()[1]
115
+
116
+
117
+ def get_week_filename(topic: str = "工作总结", ext: str = "md", date: datetime | None = None) -> str:
118
+ """生成周报标准文件名
119
+
120
+ 格式: YYYY-MM-DD_第XX周{topic}.ext
121
+ """
122
+ date = date or datetime.now()
123
+ week_num = get_week_number(date)
124
+ full_topic = f"第{week_num}周{topic}"
125
+ return build_filename(full_topic, ext, date)
wfcli/core/template.py ADDED
@@ -0,0 +1,154 @@
1
+ """模板读取与解析"""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ def load_template(template_path: Path) -> dict[str, Any]:
9
+ """加载并解析模板文件
10
+
11
+ Args:
12
+ template_path: 模板文件路径
13
+
14
+ Returns:
15
+ {
16
+ "raw_content": str, # 模板原文
17
+ "structure": list[str], # 文档骨架(标题层级)
18
+ "instructions": list[str], # AI 执行指令
19
+ "checklist": list[str], # 自检清单
20
+ "sections": dict, # 按章节拆分的内容
21
+ }
22
+ """
23
+ content = template_path.read_text(encoding="utf-8")
24
+
25
+ return {
26
+ "raw_content": content,
27
+ "structure": _extract_structure(content),
28
+ "instructions": _extract_instructions(content),
29
+ "checklist": _extract_checklist(content),
30
+ "sections": _split_sections(content),
31
+ "path": str(template_path),
32
+ "name": template_path.stem,
33
+ }
34
+
35
+
36
+ def _extract_structure(content: str) -> list[str]:
37
+ """提取文档骨架(所有标题行)"""
38
+ structure = []
39
+ for line in content.split("\n"):
40
+ if line.startswith("#"):
41
+ structure.append(line.strip())
42
+ return structure
43
+
44
+
45
+ def _extract_instructions(content: str) -> list[str]:
46
+ """提取 AI 执行指令
47
+
48
+ 通常在模板中以特定标记识别,如:
49
+ - "## AI 执行指令" 下的列表项
50
+ - 以 "- [ ]" 开头的待办项
51
+ - 包含"请"、"需要"等指令词的句子
52
+ """
53
+ instructions = []
54
+ in_instruction_section = False
55
+
56
+ for line in content.split("\n"):
57
+ stripped = line.strip()
58
+
59
+ # 检测指令章节
60
+ if re.match(r"^#+\s*(AI|执行|指令|填写|要求)", stripped, re.IGNORECASE):
61
+ in_instruction_section = True
62
+ continue
63
+ elif stripped.startswith("#") and in_instruction_section:
64
+ in_instruction_section = False
65
+ continue
66
+
67
+ if in_instruction_section:
68
+ if stripped.startswith("- ") or stripped.startswith("* "):
69
+ instructions.append(stripped[2:].strip())
70
+ elif stripped and not stripped.startswith("#"):
71
+ instructions.append(stripped)
72
+
73
+ return instructions
74
+
75
+
76
+ def _extract_checklist(content: str) -> list[str]:
77
+ """提取自检清单
78
+
79
+ 通常在 "## 自检清单" 章节,以 "- [ ]" 开头的条目
80
+ """
81
+ checklist = []
82
+ in_checklist_section = False
83
+
84
+ for line in content.split("\n"):
85
+ stripped = line.strip()
86
+
87
+ if re.match(r"^#+\s*(自检|检查|验证|checklist)", stripped, re.IGNORECASE):
88
+ in_checklist_section = True
89
+ continue
90
+ elif stripped.startswith("#") and in_checklist_section:
91
+ in_checklist_section = False
92
+ continue
93
+
94
+ if in_checklist_section:
95
+ if stripped.startswith("- [ ]") or stripped.startswith("- [x]"):
96
+ checklist.append(stripped[6:].strip())
97
+ elif stripped.startswith("- "):
98
+ checklist.append(stripped[2:].strip())
99
+
100
+ return checklist
101
+
102
+
103
+ def _split_sections(content: str) -> dict[str, str]:
104
+ """按二级标题拆分内容"""
105
+ sections = {}
106
+ current_key = ""
107
+ current_lines = []
108
+
109
+ for line in content.split("\n"):
110
+ if line.startswith("## "):
111
+ if current_key:
112
+ sections[current_key] = "\n".join(current_lines).strip()
113
+ current_key = line[3:].strip()
114
+ current_lines = []
115
+ else:
116
+ current_lines.append(line)
117
+
118
+ if current_key:
119
+ sections[current_key] = "\n".join(current_lines).strip()
120
+
121
+ return sections
122
+
123
+
124
+ def list_templates(workspace_root: Path) -> list[dict]:
125
+ """列出工作空间中所有可用模板
126
+
127
+ Returns:
128
+ [{"name": str, "path": Path, "type": str}, ...]
129
+ """
130
+ templates_dir = workspace_root / "00_模板"
131
+ if not templates_dir.exists():
132
+ return []
133
+
134
+ templates = []
135
+ for f in sorted(templates_dir.glob("*.md")):
136
+ # 从文件名推断文档类型
137
+ # 格式: {编号}_{编号}_{名称}模板.md
138
+ name = f.stem
139
+ doc_type = name
140
+ # 去掉 "模板" 后缀
141
+ if name.endswith("模板"):
142
+ doc_type = name[:-2]
143
+ # 去掉编号前缀
144
+ parts = doc_type.split("_")
145
+ if len(parts) > 2 and parts[0].isdigit():
146
+ doc_type = "_".join(parts[2:]) if len(parts) > 2 else parts[-1]
147
+
148
+ templates.append({
149
+ "name": name,
150
+ "path": f,
151
+ "type": doc_type,
152
+ })
153
+
154
+ return templates
@@ -0,0 +1,140 @@
1
+ """工作空间业务逻辑 - 目录结构解析与校验"""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from wfcli.lark import LarkClient, LarkError
7
+ from wfcli import config as cfg
8
+ from wfcli.utils.fs import parse_workspace_md, ensure_dir
9
+
10
+
11
+ def list_workspaces(client: LarkClient | None = None) -> list[dict]:
12
+ """列出云盘上所有工作空间(一级文件夹)
13
+
14
+ Returns:
15
+ 工作空间列表,每项含 name, token
16
+ """
17
+ client = client or LarkClient()
18
+ root_token = cfg.get("feishu.root_folder_token")
19
+ if not root_token:
20
+ raise LarkError("未配置 feishu.root_folder_token,请运行: wfcli config set feishu.root_folder_token <token>")
21
+
22
+ files = client.drive_list_files(root_token)
23
+ return [
24
+ {"name": f["name"], "token": f.get("token", ""), "type": f.get("type", "")}
25
+ for f in files
26
+ if f.get("type") == "folder"
27
+ ]
28
+
29
+
30
+ def check_workspace_exists(name: str, client: LarkClient | None = None) -> dict | None:
31
+ """检查云盘上是否已存在同名工作空间
32
+
33
+ Returns:
34
+ 已存在则返回 {"name": ..., "token": ...},否则 None
35
+ """
36
+ workspaces = list_workspaces(client)
37
+ for ws in workspaces:
38
+ if ws["name"] == name:
39
+ return ws
40
+ return None
41
+
42
+
43
+ def download_workspace(
44
+ workspace_token: str,
45
+ local_dir: Path,
46
+ client: LarkClient | None = None,
47
+ ) -> Path:
48
+ """下载工作空间到本地
49
+
50
+ Args:
51
+ workspace_token: 工作空间文件夹 token
52
+ local_dir: 本地目标目录
53
+ client: LarkClient 实例
54
+
55
+ Returns:
56
+ 本地工作空间根目录
57
+ """
58
+ client = client or LarkClient()
59
+ ensure_dir(local_dir)
60
+
61
+ # 1. 获取工作空间根目录文件列表
62
+ files = client.drive_list_files(workspace_token)
63
+
64
+ # 2. 下载 WORKSPACE.md
65
+ ws_file = next((f for f in files if f.get("name") == "WORKSPACE.md"), None)
66
+ if not ws_file:
67
+ raise LarkError("工作空间中未找到 WORKSPACE.md")
68
+
69
+ ws_token = ws_file.get("token", "")
70
+ client.drive_download(ws_token, local_dir)
71
+
72
+ # 3. 解析 WORKSPACE.md 获取目录结构
73
+ ws_md_path = local_dir / "WORKSPACE.md"
74
+ ws_info = parse_workspace_md(ws_md_path)
75
+
76
+ # 4. 创建本地子目录
77
+ for d in ws_info.get("directories", []):
78
+ dir_name = d["name"]
79
+ ensure_dir(local_dir / dir_name)
80
+
81
+ # 5. 下载模板文件
82
+ templates_dir = next(
83
+ (f for f in files if f.get("name", "").startswith("00_")),
84
+ None
85
+ )
86
+ if templates_dir:
87
+ template_token = templates_dir.get("token", "")
88
+ template_files = client.drive_list_files(template_token)
89
+ local_templates_dir = ensure_dir(local_dir / templates_dir["name"])
90
+ for tf in template_files:
91
+ if tf.get("type") != "folder":
92
+ ft = tf.get("token", "")
93
+ if ft:
94
+ client.drive_download(ft, local_templates_dir)
95
+
96
+ return local_dir
97
+
98
+
99
+ def build_workspace_dirs(local_dir: Path, directories: list[dict]) -> None:
100
+ """根据目录结构定义创建本地目录
101
+
102
+ Args:
103
+ local_dir: 工作空间根目录
104
+ directories: 目录结构列表 [{"name": ..., "depth": ...}, ...]
105
+ """
106
+ for d in directories:
107
+ ensure_dir(local_dir / d["name"])
108
+
109
+
110
+ def match_cloud_folder(
111
+ relative_path: str,
112
+ workspace_token: str,
113
+ client: LarkClient | None = None,
114
+ ) -> str:
115
+ """根据本地文件的相对路径,匹配云盘上对应的文件夹 token
116
+
117
+ Args:
118
+ relative_path: 文件相对于工作空间根目录的路径,如 "01_会议纪要/2026-06-16_xxx.md"
119
+ workspace_token: 工作空间文件夹 token
120
+ client: LarkClient 实例
121
+
122
+ Returns:
123
+ 云盘目标文件夹的 token
124
+ """
125
+ client = client or LarkClient()
126
+ parts = Path(relative_path).parts[:-1] # 去掉文件名,只保留目录路径
127
+
128
+ current_token = workspace_token
129
+ for part in parts:
130
+ files = client.drive_list_files(current_token)
131
+ matched = next(
132
+ (f for f in files if f.get("name") == part and f.get("type") == "folder"),
133
+ None
134
+ )
135
+ if matched:
136
+ current_token = matched.get("token", "")
137
+ else:
138
+ raise LarkError(f"云盘上未找到对应目录: {part}")
139
+
140
+ return current_token