devmate 1.0.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.
@@ -0,0 +1,280 @@
1
+ """
2
+ notekeeper — 笔记知识库
3
+
4
+ 基于 SQLite 的轻量笔记系统,支持 FTS5 全文搜索、标签管理、Markdown 导入导出。
5
+ """
6
+
7
+ import json
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from sqlalchemy import create_engine, text
14
+
15
+ from devmate.bus.command import CommandResult
16
+
17
+ # ── 数据库初始化 ────────────────────────────────────
18
+
19
+ DB_PATH = Path.home() / ".devmate" / "notes.db"
20
+ engine = create_engine(f"sqlite:///{DB_PATH}", pool_pre_ping=True)
21
+
22
+
23
+ def _init_db():
24
+ """初始化数据库表"""
25
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
26
+ with engine.connect() as conn:
27
+ conn.execute(text("""
28
+ CREATE TABLE IF NOT EXISTS notes (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ title TEXT NOT NULL,
31
+ content TEXT NOT NULL DEFAULT '',
32
+ tags TEXT NOT NULL DEFAULT '',
33
+ created_at TEXT NOT NULL,
34
+ updated_at TEXT NOT NULL
35
+ )
36
+ """))
37
+ conn.execute(text("""
38
+ CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
39
+ title, content, content=notes, content_rowid=id
40
+ )
41
+ """))
42
+ conn.commit()
43
+
44
+
45
+ # ── 笔记 CRUD ──────────────────────────────────────
46
+
47
+ @dataclass
48
+ class Note:
49
+ """笔记数据"""
50
+ id: int
51
+ title: str
52
+ content: str
53
+ tags: list[str] = field(default_factory=list)
54
+ created_at: str = ""
55
+ updated_at: str = ""
56
+
57
+ def to_dict(self) -> dict:
58
+ return {
59
+ "id": self.id, "title": self.title,
60
+ "content": self.content[:200] if len(self.content) > 200 else self.content,
61
+ "tags": self.tags, "created_at": self.created_at,
62
+ "updated_at": self.updated_at,
63
+ }
64
+
65
+
66
+ def _row_to_note(row) -> Note:
67
+ tags = []
68
+ if row.tags:
69
+ tags = json.loads(row.tags)
70
+ return Note(id=row.id, title=row.title, content=row.content,
71
+ tags=tags, created_at=row.created_at, updated_at=row.updated_at)
72
+
73
+
74
+ def _now() -> str:
75
+ return time.strftime("%Y-%m-%d %H:%M:%S")
76
+
77
+
78
+ _init_db()
79
+
80
+
81
+ def new(title: str, content: str = "", tags: str = "") -> CommandResult:
82
+ """创建笔记"""
83
+ now = _now()
84
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
85
+ with engine.connect() as conn:
86
+ result = conn.execute(
87
+ text("INSERT INTO notes (title, content, tags, created_at, updated_at) "
88
+ "VALUES (:title, :content, :tags, :ca, :ua)"),
89
+ {"title": title, "content": content, "tags": json.dumps(tag_list),
90
+ "ca": now, "ua": now},
91
+ )
92
+ note_id = result.lastrowid
93
+ # 同步 FTS
94
+ conn.execute(text("INSERT INTO notes_fts(rowid, title, content) VALUES (:id, :t, :c)"),
95
+ {"id": note_id, "t": title, "c": content})
96
+ conn.commit()
97
+
98
+ return CommandResult(success=True, data={"id": note_id, "message": f"✅ 笔记已创建: {title}"})
99
+
100
+
101
+ def list_notes(limit: int = 20, offset: int = 0) -> CommandResult:
102
+ """列出笔记"""
103
+ with engine.connect() as conn:
104
+ rows = conn.execute(
105
+ text("SELECT id, title, content, tags, created_at, updated_at "
106
+ "FROM notes ORDER BY updated_at DESC LIMIT :lim OFFSET :off"),
107
+ {"lim": limit, "off": offset},
108
+ ).fetchall()
109
+
110
+ count = conn.execute(text("SELECT COUNT(*) FROM notes")).scalar()
111
+
112
+ notes = [_row_to_note(r) for r in rows]
113
+ return CommandResult(success=True, data={
114
+ "notes": [n.to_dict() for n in notes],
115
+ "total": count,
116
+ })
117
+
118
+
119
+ def show(note_id: int) -> CommandResult:
120
+ """查看单条笔记"""
121
+ with engine.connect() as conn:
122
+ row = conn.execute(
123
+ text("SELECT * FROM notes WHERE id = :id"), {"id": note_id}
124
+ ).fetchone()
125
+
126
+ if row is None:
127
+ return CommandResult(success=False, error=f"笔记不存在: {note_id}")
128
+
129
+ note = _row_to_note(row)
130
+ return CommandResult(success=True, data=note.to_dict())
131
+
132
+
133
+ def edit(note_id: int, title: str = "", content: str = "",
134
+ tags: str = "") -> CommandResult:
135
+ """编辑笔记"""
136
+ updates: list[str] = []
137
+ params: dict[str, Any] = {"id": note_id}
138
+
139
+ if title:
140
+ updates.append("title = :title")
141
+ params["title"] = title
142
+ if content:
143
+ updates.append("content = :content")
144
+ params["content"] = content
145
+ if tags:
146
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()]
147
+ params["tags"] = json.dumps(tag_list)
148
+ updates.append("tags = :tags")
149
+
150
+ if not updates:
151
+ return CommandResult(success=False, error="没有要更新的内容")
152
+
153
+ updates.append("updated_at = :ua")
154
+ params["ua"] = _now()
155
+
156
+ with engine.connect() as conn:
157
+ conn.execute(
158
+ text(f"UPDATE notes SET {', '.join(updates)} WHERE id = :id"),
159
+ params,
160
+ )
161
+ # 同步 FTS
162
+ if title or content:
163
+ row = conn.execute(
164
+ text("SELECT title, content FROM notes WHERE id = :id"), {"id": note_id}
165
+ ).fetchone()
166
+ if row:
167
+ conn.execute(text(
168
+ "DELETE FROM notes_fts WHERE rowid = :id"
169
+ ), {"id": note_id})
170
+ conn.execute(text(
171
+ "INSERT INTO notes_fts(rowid, title, content) VALUES (:id, :t, :c)"
172
+ ), {"id": note_id, "t": row[0], "c": row[1]})
173
+ conn.commit()
174
+
175
+ return CommandResult(success=True, data={"message": "✅ 笔记已更新"})
176
+
177
+
178
+
179
+ def remove(note_id: int) -> CommandResult:
180
+ """删除笔记"""
181
+ with engine.connect() as conn:
182
+ conn.execute(text("DELETE FROM notes WHERE id = :id"), {"id": note_id})
183
+ conn.execute(text("DELETE FROM notes_fts WHERE rowid = :id"), {"id": note_id})
184
+ conn.commit()
185
+
186
+ return CommandResult(success=True, data={"message": "✅ 笔记已删除"})
187
+
188
+
189
+ # ── FTS5 全文搜索 ──────────────────────────────────
190
+
191
+ def search(query: str, limit: int = 20) -> CommandResult:
192
+ """全文搜索笔记"""
193
+ with engine.connect() as conn:
194
+ rows = conn.execute(
195
+ text("SELECT n.id, n.title, n.content, n.tags, n.created_at, n.updated_at "
196
+ "FROM notes_fts f JOIN notes n ON f.rowid = n.id "
197
+ "WHERE notes_fts MATCH :q ORDER BY rank LIMIT :lim"),
198
+ {"q": query, "lim": limit},
199
+ ).fetchall()
200
+
201
+ notes = [_row_to_note(r) for r in rows]
202
+ return CommandResult(success=True, data={
203
+ "notes": [n.to_dict() for n in notes],
204
+ "total": len(notes),
205
+ "query": query,
206
+ })
207
+
208
+
209
+ # ── 标签管理 ───────────────────────────────────────
210
+
211
+ def tag_list() -> CommandResult:
212
+ """列出所有标签"""
213
+ with engine.connect() as conn:
214
+ rows = conn.execute(text("SELECT tags FROM notes")).fetchall()
215
+
216
+ tag_count: dict[str, int] = {}
217
+ for row in rows:
218
+ if row.tags:
219
+ for t in json.loads(row.tags):
220
+ tag_count[t] = tag_count.get(t, 0) + 1
221
+
222
+ tags = [{"name": k, "count": v} for k, v in sorted(tag_count.items())]
223
+ return CommandResult(success=True, data={"tags": tags})
224
+
225
+
226
+ # ── Markdown 导入导出 ──────────────────────────────
227
+
228
+ def export_note(note_id: int, output: str = "") -> CommandResult:
229
+ """导出笔记为 Markdown"""
230
+ result = show(note_id)
231
+ if not result.success:
232
+ return result
233
+
234
+ note = result.data
235
+ tags_str = ", ".join(note.get("tags", []))
236
+ md = f"""---
237
+ title: "{note['title']}"
238
+ created: {note.get('created_at', '')}
239
+ updated: {note.get('updated_at', '')}
240
+ tags: [{tags_str}]
241
+ ---
242
+
243
+ {note.get('content', '')}
244
+ """
245
+
246
+ if output:
247
+ out_path = Path(output).expanduser()
248
+ out_path.write_text(md, encoding="utf-8")
249
+ return CommandResult(success=True, data={"message": f"已导出: {out_path}", "path": str(out_path)}) # noqa: E501
250
+
251
+ return CommandResult(success=True, data={"markdown": md})
252
+
253
+
254
+ def import_markdown(path: str) -> CommandResult:
255
+ """从 Markdown 文件导入笔记"""
256
+ file_path = Path(path).expanduser()
257
+ if not file_path.exists():
258
+ return CommandResult(success=False, error=f"文件不存在: {path}")
259
+
260
+ content = file_path.read_text(encoding="utf-8")
261
+
262
+ # 解析 frontmatter
263
+ title = file_path.stem
264
+ tags = ""
265
+ body = content
266
+
267
+ if content.startswith("---"):
268
+ parts = content.split("---", 2)
269
+ if len(parts) >= 3:
270
+ front = parts[1]
271
+ body = parts[2].strip()
272
+ for line in front.split("\n"):
273
+ line = line.strip()
274
+ if line.startswith("title:"):
275
+ title = line.split(":", 1)[1].strip().strip('"\'')
276
+ elif line.startswith("tags:"):
277
+ tag_str = line.split(":", 1)[1].strip()
278
+ tags = tag_str.strip("[]").replace(",", ",")
279
+
280
+ return new(title, body, tags)
@@ -0,0 +1,212 @@
1
+ """
2
+ regexlab — 正则表达式实验室
3
+
4
+ 包含正则测试、文件搜索、常用正则库等实用功能。
5
+ """
6
+
7
+ import json
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from devmate.bus.command import CommandResult
13
+
14
+ # ── 内置正则库 ──────────────────────────────────────
15
+
16
+ BUILTIN_PATTERNS: dict[str, dict[str, str]] = {
17
+ "邮箱": {
18
+ "pattern": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
19
+ "desc": "匹配常见邮箱地址",
20
+ "example": "user@example.com",
21
+ },
22
+ "手机号": {
23
+ "pattern": r"^1[3-9]\d{9}$",
24
+ "desc": "匹配中国大陆手机号",
25
+ "example": "13800138000",
26
+ },
27
+ "URL": {
28
+ "pattern": r"https?://[^\s/$.?#].[^\s]*",
29
+ "desc": "匹配 HTTP/HTTPS URL",
30
+ "example": "https://example.com/path?q=1",
31
+ },
32
+ "IPv4": {
33
+ "pattern": r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
34
+ "desc": "匹配 IPv4 地址",
35
+ "example": "192.168.1.1",
36
+ },
37
+ "身份证号": {
38
+ "pattern": r"^[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]$",
39
+ "desc": "匹配 18 位身份证号",
40
+ "example": "110101199001011234",
41
+ },
42
+ "日期 (YYYY-MM-DD)": {
43
+ "pattern": r"\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])",
44
+ "desc": "匹配 YYYY-MM-DD 格式日期",
45
+ "example": "2026-07-24",
46
+ },
47
+ "中文字符": {
48
+ "pattern": r"[\u4e00-\u9fff]+",
49
+ "desc": "匹配连续的中文字符",
50
+ "example": "你好世界",
51
+ },
52
+ }
53
+
54
+ CUSTOM_LIB_FILE = Path.home() / ".devmate" / "regex_lib.json"
55
+
56
+
57
+ # ── 正则测试 ────────────────────────────────────────
58
+
59
+ def match(pattern: str, text: str, flags: str = "") -> CommandResult:
60
+ """测试正则匹配"""
61
+ try:
62
+ flag_val = _parse_flags(flags)
63
+ compiled = re.compile(pattern, flag_val)
64
+ except re.error as e:
65
+ return CommandResult(success=False, error=f"正则语法错误: {e}",
66
+ suggestion="检查正则表达式中的特殊字符是否转义正确")
67
+
68
+ results: list[dict[str, Any]] = []
69
+ for m in compiled.finditer(text):
70
+ start, end = m.span()
71
+ results.append({
72
+ "matched": m.group(),
73
+ "start": start,
74
+ "end": end,
75
+ "groups": [g for g in m.groups() if g is not None],
76
+ })
77
+
78
+ return CommandResult(success=True, data={
79
+ "pattern": pattern,
80
+ "text_length": len(text),
81
+ "matches": results,
82
+ "total": len(results),
83
+ "flags": flags,
84
+ })
85
+
86
+
87
+ def _parse_flags(flags: str) -> int:
88
+ """解析标志字符串为 re 标志位"""
89
+ flag_val = 0
90
+ flag_map = {
91
+ "i": re.IGNORECASE,
92
+ "m": re.MULTILINE,
93
+ "s": re.DOTALL,
94
+ "x": re.VERBOSE,
95
+ "u": re.UNICODE,
96
+ }
97
+ for f in flags.lower():
98
+ if f in flag_map:
99
+ flag_val |= flag_map[f]
100
+ return flag_val
101
+
102
+
103
+ # ── 文件搜索 ────────────────────────────────────────
104
+
105
+ def search(pattern: str, path: str, flags: str = "",
106
+ max_results: int = 50) -> CommandResult:
107
+ """在文件中搜索正则"""
108
+ target = Path(path).expanduser().resolve()
109
+ if not target.exists():
110
+ return CommandResult(success=False, error=f"路径不存在: {path}")
111
+
112
+ try:
113
+ flag_val = _parse_flags(flags)
114
+ compiled = re.compile(pattern, flag_val)
115
+ except re.error as e:
116
+ return CommandResult(success=False, error=f"正则语法错误: {e}")
117
+
118
+ files_checked = 0
119
+ matches_found = 0
120
+ file_matches: list[dict[str, Any]] = []
121
+
122
+ if target.is_file():
123
+ files = [target]
124
+ else:
125
+ files = list(target.rglob("*"))[:200] # 最多检查 200 个文件
126
+
127
+ for file_path in files:
128
+ if file_path.is_dir() or file_path.is_symlink():
129
+ continue
130
+ try:
131
+ # 只检查文本文件
132
+ if file_path.suffix in (".pyc", ".pyo", ".so", ".dll", ".dylib", ".bin"):
133
+ continue
134
+ content = file_path.read_text(errors="ignore")
135
+ files_checked += 1
136
+ if matches_found >= max_results:
137
+ break
138
+ for i, line in enumerate(content.split("\n"), 1):
139
+ if compiled.search(line):
140
+ if matches_found >= max_results:
141
+ break
142
+ file_rel = (
143
+ file_path.relative_to(target)
144
+ if target.is_dir() else file_path.name
145
+ )
146
+ file_matches.append({
147
+ "file": str(file_rel),
148
+ "line": i,
149
+ "content": line.strip()[:100],
150
+ })
151
+ matches_found += 1
152
+ except (OSError, UnicodeDecodeError):
153
+ continue
154
+
155
+ return CommandResult(success=True, data={
156
+ "pattern": pattern,
157
+ "target": str(target),
158
+ "files_checked": files_checked,
159
+ "matches": file_matches,
160
+ "total": matches_found,
161
+ })
162
+
163
+
164
+ # ── 正则库 ──────────────────────────────────────────
165
+
166
+ def lib_list() -> CommandResult:
167
+ """列出内置和自定义正则"""
168
+ custom = []
169
+ if CUSTOM_LIB_FILE.exists():
170
+ custom = json.loads(CUSTOM_LIB_FILE.read_text())
171
+
172
+ builtin_list = [{"name": k, **v} for k, v in BUILTIN_PATTERNS.items()]
173
+
174
+ return CommandResult(success=True, data={
175
+ "builtin": builtin_list,
176
+ "custom": custom,
177
+ })
178
+
179
+
180
+ def lib_add(name: str, pattern: str, desc: str = "", example: str = "") -> CommandResult:
181
+ """添加自定义正则"""
182
+ CUSTOM_LIB_FILE.parent.mkdir(parents=True, exist_ok=True)
183
+ lib = []
184
+ if CUSTOM_LIB_FILE.exists():
185
+ lib = json.loads(CUSTOM_LIB_FILE.read_text())
186
+
187
+ # 验证正则
188
+ try:
189
+ re.compile(pattern)
190
+ except re.error as e:
191
+ return CommandResult(success=False, error=f"正则语法错误: {e}")
192
+
193
+ # 更新或添加
194
+ lib = [item for item in lib if item.get("name") != name]
195
+ lib.append({"name": name, "pattern": pattern, "desc": desc, "example": example})
196
+ CUSTOM_LIB_FILE.write_text(json.dumps(lib, indent=2, ensure_ascii=False))
197
+ return CommandResult(success=True, data={"message": f"已添加正则: {name}"})
198
+
199
+
200
+ def lib_remove(name: str) -> CommandResult:
201
+ """删除自定义正则"""
202
+ if not CUSTOM_LIB_FILE.exists():
203
+ return CommandResult(success=False, error="自定义正则库为空")
204
+
205
+ lib = json.loads(CUSTOM_LIB_FILE.read_text())
206
+ before = len(lib)
207
+ lib = [item for item in lib if item.get("name") != name]
208
+ if len(lib) == before:
209
+ return CommandResult(success=False, error=f"未找到正则: {name}")
210
+
211
+ CUSTOM_LIB_FILE.write_text(json.dumps(lib, indent=2, ensure_ascii=False))
212
+ return CommandResult(success=True, data={"message": f"已删除正则: {name}"})
@@ -0,0 +1,170 @@
1
+ """
2
+ reporter — 报告自动生成
3
+
4
+ 基于 git log + 笔记的日报/周报,支持多模板和 Markdown/HTML 导出。
5
+ """
6
+
7
+ import subprocess
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from devmate.bus.command import CommandResult
12
+
13
+ REPORTS_DIR = Path.home() / ".devmate" / "reports"
14
+
15
+
16
+ TEMPLATES: dict[str, dict[str, str]] = {
17
+ "minimal": {
18
+ "name": "简洁",
19
+ "desc": "只包含核心要点",
20
+ "header": "# {title}\n\n**周期* # noqa: E501*: {period}\n\n",
21
+ "section_commits": "## 提交\n\n{commits}\n",
22
+ "section_notes": "## 笔记\n\n{notes}\n",
23
+ },
24
+ "standup": {
25
+ "name": "站会",
26
+ "desc": "昨日/今日/阻塞",
27
+ "header": "# 站会报告 - {period}\n\n",
28
+ "section_commits": "## 昨日完成\n\n{commits}\n",
29
+ "section_notes": "## 今日计划\n\n{notes}\n",
30
+ "section_blockers": "## 阻塞项\n\n{none}",
31
+ },
32
+ "detailed": {
33
+ "name": "详细",
34
+ "desc": "包含统计 + 变更明细",
35
+ "header": "# {title}\n\n**周期* # noqa: E501*: {period}\n\n## 统计\n\n- 提交数: {commit_count}\n- 变更文件: {file_count}\n\n", # noqa: E501
36
+ "section_commits": "## 提交明细\n\n| 时间 | 提交 | 说明 |\n|------|------|------|\n{commits}\n", # noqa: E501
37
+ "section_notes": "## 笔记\n\n{notes}\n",
38
+ },
39
+ }
40
+
41
+
42
+ def _git_log(since: str, path: str = ".") -> tuple[list[dict], int]:
43
+ """获取 git 日志"""
44
+ try:
45
+ result = subprocess.run(
46
+ ["git", "log", "--oneline", "--since", since,
47
+ "--format=%h|||%s|||%an|||%ai"],
48
+ capture_output=True, text=True, cwd=path, timeout=10,
49
+ )
50
+ commits = []
51
+ for line in result.stdout.strip().split("\n"):
52
+ if not line.strip():
53
+ continue
54
+ parts = line.split("|||", 3)
55
+ if len(parts) == 4:
56
+ commits.append({
57
+ "hash": parts[0], "subject": parts[1],
58
+ "author": parts[2], "date": parts[3][:10],
59
+ })
60
+
61
+ # 变更文件数
62
+ stat_result = subprocess.run(
63
+ ["git", "diff", "--stat", f"@{'{since}'}", "HEAD"],
64
+ capture_output=True, text=True, cwd=path, timeout=5,
65
+ )
66
+ file_count = len(stat_result.stdout.strip().split("\n")) if stat_result.stdout else 0
67
+
68
+ return commits, file_count
69
+ except Exception:
70
+ return [], 0
71
+
72
+
73
+ def _get_notes(since: str) -> list[dict]:
74
+ """获取指定时间后的笔记"""
75
+ try:
76
+ from devmate_notekeeper import list_notes
77
+ result = list_notes(limit=50)
78
+ if result.success:
79
+ notes = []
80
+ for n in result.data["notes"]:
81
+ if n.get("updated_at", "").startswith(since[:10]):
82
+ notes.append(n)
83
+ return notes
84
+ except Exception:
85
+ pass
86
+ return []
87
+
88
+
89
+ def generate(period: str = "daily", template: str = "standup",
90
+ path: str = ".", title: str = "") -> CommandResult:
91
+ """生成报告"""
92
+ if template not in TEMPLATES:
93
+ types = ", ".join(TEMPLATES.keys())
94
+ return CommandResult(success=False, error=f"不支持的模板: {template}。可选: {types}")
95
+
96
+ if period == "daily":
97
+ since = "24 hours ago"
98
+ period_label = "日报"
99
+ elif period == "weekly":
100
+ since = "7 days ago"
101
+ period_label = "周报"
102
+ else:
103
+ since = period
104
+ period_label = period
105
+
106
+ commits, file_count = _git_log(since, path)
107
+ notes = _get_notes(since)
108
+
109
+ tmpl = TEMPLATES[template]
110
+ period_str = time.strftime("%Y-%m-%d")
111
+
112
+ # 渲染提交
113
+ commit_lines = []
114
+ for c in commits[:30]:
115
+ commit_lines.append(f"- {c['subject']} ({c['hash']})")
116
+ commits_str = "\n".join(commit_lines) if commit_lines else "(无提交)"
117
+
118
+ # 渲染笔记
119
+ note_lines = []
120
+ for n in notes:
121
+ note_lines.append(f"- {n['title']}")
122
+ notes_str = "\n".join(note_lines) if note_lines else "(无笔记)"
123
+
124
+ title = title or f"{period_label} - {period_str}"
125
+
126
+ # 填充模板
127
+ report = tmpl["header"].format(title=title, period=period_str)
128
+ report += tmpl["section_commits"].format(commits=commits_str)
129
+ report += tmpl["section_notes"].format(notes=notes_str)
130
+ if "section_blockers" in tmpl:
131
+ report += tmpl["section_blockers"].format(none="(无)")
132
+
133
+ # 保存
134
+ now = time.strftime("%Y%m%d_%H%M%S")
135
+ REPORTS_DIR.mkdir(parents=True, exist_ok=True)
136
+ report_file = REPORTS_DIR / f"report_{now}.md"
137
+ report_file.write_text(report, encoding="utf-8")
138
+
139
+ return CommandResult(success=True, data={
140
+ "markdown": report,
141
+ "path": str(report_file),
142
+ "commit_count": len(commit_lines),
143
+ "period": period_label,
144
+ "template": template,
145
+ })
146
+
147
+
148
+ def export_html(markdown_path: str) -> CommandResult:
149
+ """导出报告为 HTML"""
150
+ md_file = Path(markdown_path).expanduser()
151
+ if not md_file.exists():
152
+ return CommandResult(success=False, error=f"文件不存在: {markdown_path}")
153
+
154
+ content = md_file.read_text(encoding="utf-8")
155
+ html = f"""<!DOCTYPE html>
156
+ <html><head><meta charset="utf-8">
157
+ <title>DevMate 报告</title>
158
+ <style>
159
+ body {{ max-width: 800px; margin: auto; padding: 2em;
160
+ font-family: -apple-system, sans-serif; line-height: 1.6; }}
161
+ h1 {{ color: #333; border-bottom: 2px solid #eee; }}
162
+ h2 {{ color: #555; }}
163
+ pre {{ background: #f5f5f5; padding: 1em; border-radius: 4px; }}
164
+ </style></head><body>
165
+ <pre>{content}</pre>
166
+ </body></html>"""
167
+
168
+ html_file = md_file.with_suffix(".html")
169
+ html_file.write_text(html, encoding="utf-8")
170
+ return CommandResult(success=True, data={"path": str(html_file)})