jfox-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.
jfox/template.py ADDED
@@ -0,0 +1,301 @@
1
+ """Template management for standardized note creation"""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import yaml
9
+ from jinja2 import Template, UndefinedError
10
+
11
+ from .models import NoteType
12
+
13
+
14
+ @dataclass
15
+ class NoteTemplate:
16
+ """Template definition for creating notes"""
17
+ name: str
18
+ description: str
19
+ note_type: str
20
+ title_format: str
21
+ content: str
22
+ tags: List[str] = field(default_factory=list)
23
+ is_builtin: bool = False
24
+
25
+
26
+ class TemplateError(Exception):
27
+ """Template-related error"""
28
+ pass
29
+
30
+
31
+ class TemplateNotFoundError(TemplateError):
32
+ """Template not found error"""
33
+ pass
34
+
35
+
36
+ class TemplateRenderError(TemplateError):
37
+ """Template rendering error"""
38
+ pass
39
+
40
+
41
+ class TemplateManager:
42
+ """Manage note templates"""
43
+
44
+ # Built-in template definitions
45
+ BUILTIN_TEMPLATES = {
46
+ "quick": {
47
+ "name": "quick",
48
+ "description": "快速记录想法",
49
+ "note_type": "fleeting",
50
+ "title_format": "{{date}}-{{title}}",
51
+ "content": '## 快速笔记\n\n创建于 {{datetime}}\n\n{{content}}',
52
+ "tags": ["fleeting"],
53
+ },
54
+ "meeting": {
55
+ "name": "meeting",
56
+ "description": "会议记录模板",
57
+ "note_type": "permanent",
58
+ "title_format": "{{date}}-{{title}}",
59
+ "content": '## 会议信息\n- **日期**: {{date}}\n- **时间**: {{time}}\n\n## 参会人员\n\n## 议程\n\n## 会议内容\n\n{{content}}\n\n## 行动项\n- [ ] ',
60
+ "tags": ["meeting", "permanent"],
61
+ },
62
+ "literature": {
63
+ "name": "literature",
64
+ "description": "阅读笔记模板",
65
+ "note_type": "literature",
66
+ "title_format": "{{title}}",
67
+ "content": '## 文献信息\n- **来源**: {{source}}\n- **作者**: \n- **阅读日期**: {{date}}\n\n## 核心观点\n\n## 个人思考\n\n## 关联笔记\n\n{{content}}',
68
+ "tags": ["literature"],
69
+ },
70
+ }
71
+
72
+ def __init__(self, templates_dir: Path):
73
+ """
74
+ Initialize template manager
75
+
76
+ Args:
77
+ templates_dir: Directory to store templates
78
+ """
79
+ self.templates_dir = templates_dir
80
+ self._ensure_templates_dir()
81
+ self._ensure_builtin_templates()
82
+
83
+ def _ensure_templates_dir(self) -> None:
84
+ """Create templates directory if it doesn't exist"""
85
+ self.templates_dir.mkdir(parents=True, exist_ok=True)
86
+
87
+ def _ensure_builtin_templates(self) -> None:
88
+ """Create built-in templates if they don't exist"""
89
+ for name, template_data in self.BUILTIN_TEMPLATES.items():
90
+ template_path = self.templates_dir / f"{name}.yaml"
91
+ if not template_path.exists():
92
+ self._save_template_file(template_path, template_data, is_builtin=True)
93
+
94
+ def _save_template_file(self, path: Path, data: Dict[str, Any], is_builtin: bool = False) -> None:
95
+ """Save template to YAML file"""
96
+ template_data = {
97
+ "name": data["name"],
98
+ "description": data["description"],
99
+ "note_type": data["note_type"],
100
+ "title_format": data["title_format"],
101
+ "content": data["content"],
102
+ "tags": data.get("tags", []),
103
+ "is_builtin": is_builtin,
104
+ }
105
+ with open(path, "w", encoding="utf-8") as f:
106
+ yaml.dump(template_data, f, allow_unicode=True, sort_keys=False)
107
+
108
+ def list_templates(self) -> List[NoteTemplate]:
109
+ """
110
+ List all available templates
111
+
112
+ Returns:
113
+ List of NoteTemplate objects
114
+ """
115
+ templates = []
116
+
117
+ # Scan templates directory for YAML files
118
+ for template_file in self.templates_dir.glob("*.yaml"):
119
+ try:
120
+ template = self._load_template_file(template_file)
121
+ if template:
122
+ templates.append(template)
123
+ except Exception:
124
+ # Skip invalid template files
125
+ continue
126
+
127
+ # Sort by name
128
+ templates.sort(key=lambda t: t.name)
129
+ return templates
130
+
131
+ def _load_template_file(self, path: Path) -> Optional[NoteTemplate]:
132
+ """Load a single template from file"""
133
+ with open(path, "r", encoding="utf-8") as f:
134
+ data = yaml.safe_load(f)
135
+
136
+ if not data:
137
+ return None
138
+
139
+ return NoteTemplate(
140
+ name=data.get("name", path.stem),
141
+ description=data.get("description", ""),
142
+ note_type=data.get("note_type", "fleeting"),
143
+ title_format=data.get("title_format", "{{title}}"),
144
+ content=data.get("content", ""),
145
+ tags=data.get("tags", []),
146
+ is_builtin=data.get("is_builtin", False),
147
+ )
148
+
149
+ def get_template(self, name: str) -> Optional[NoteTemplate]:
150
+ """
151
+ Get a template by name
152
+
153
+ Args:
154
+ name: Template name
155
+
156
+ Returns:
157
+ NoteTemplate or None if not found
158
+ """
159
+ template_path = self.templates_dir / f"{name}.yaml"
160
+
161
+ if not template_path.exists():
162
+ return None
163
+
164
+ return self._load_template_file(template_path)
165
+
166
+ def render(self, name: str, variables: Dict[str, Any]) -> Dict[str, Any]:
167
+ """
168
+ Render a template with variables
169
+
170
+ Args:
171
+ name: Template name
172
+ variables: Variables to use in rendering
173
+
174
+ Returns:
175
+ Dict with rendered title, content, note_type, and tags
176
+
177
+ Raises:
178
+ TemplateNotFoundError: If template doesn't exist
179
+ TemplateRenderError: If template rendering fails
180
+ """
181
+ template = self.get_template(name)
182
+
183
+ if not template:
184
+ available = [t.name for t in self.list_templates()]
185
+ raise TemplateNotFoundError(
186
+ f"Template '{name}' not found. "
187
+ f"Available templates: {', '.join(available) if available else 'none'}"
188
+ )
189
+
190
+ # Merge provided variables with defaults
191
+ render_vars = self._get_default_variables()
192
+ render_vars.update(variables)
193
+
194
+ try:
195
+ # Render title
196
+ title_template = Template(template.title_format)
197
+ rendered_title = title_template.render(**render_vars)
198
+
199
+ # Render content
200
+ content_template = Template(template.content)
201
+ rendered_content = content_template.render(**render_vars)
202
+
203
+ except UndefinedError as e:
204
+ raise TemplateRenderError(f"Template variable undefined: {e}")
205
+ except Exception as e:
206
+ raise TemplateRenderError(f"Failed to render template: {e}")
207
+
208
+ return {
209
+ "title": rendered_title,
210
+ "content": rendered_content,
211
+ "note_type": template.note_type,
212
+ "tags": template.tags.copy(),
213
+ }
214
+
215
+ def _get_default_variables(self) -> Dict[str, str]:
216
+ """Get default template variables (date, time, etc.)"""
217
+ now = datetime.now()
218
+ return {
219
+ "date": now.strftime("%Y-%m-%d"),
220
+ "time": now.strftime("%H:%M"),
221
+ "datetime": now.strftime("%Y-%m-%d %H:%M"),
222
+ }
223
+
224
+ def get_available_templates(self) -> List[str]:
225
+ """Get list of available template names"""
226
+ return [t.name for t in self.list_templates()]
227
+
228
+ def create_template(self, name: str, description: str, note_type: str,
229
+ title_format: str, content: str, tags: List[str]) -> NoteTemplate:
230
+ """
231
+ Create a new custom template
232
+
233
+ Args:
234
+ name: Template name
235
+ description: Template description
236
+ note_type: Note type (fleeting/literature/permanent)
237
+ title_format: Title format with variables
238
+ content: Template content
239
+ tags: Default tags
240
+
241
+ Returns:
242
+ Created NoteTemplate
243
+
244
+ Raises:
245
+ TemplateError: If template already exists
246
+ """
247
+ template_path = self.templates_dir / f"{name}.yaml"
248
+
249
+ if template_path.exists():
250
+ raise TemplateError(f"Template '{name}' already exists")
251
+
252
+ template_data = {
253
+ "name": name,
254
+ "description": description,
255
+ "note_type": note_type,
256
+ "title_format": title_format,
257
+ "content": content,
258
+ "tags": tags,
259
+ "is_builtin": False,
260
+ }
261
+
262
+ self._save_template_file(template_path, template_data, is_builtin=False)
263
+
264
+ return NoteTemplate(
265
+ name=name,
266
+ description=description,
267
+ note_type=note_type,
268
+ title_format=title_format,
269
+ content=content,
270
+ tags=tags,
271
+ is_builtin=False,
272
+ )
273
+
274
+ def delete_template(self, name: str) -> None:
275
+ """
276
+ Delete a custom template
277
+
278
+ Args:
279
+ name: Template name
280
+
281
+ Raises:
282
+ TemplateNotFoundError: If template doesn't exist
283
+ TemplateError: If trying to delete a built-in template
284
+ """
285
+ template = self.get_template(name)
286
+
287
+ if not template:
288
+ raise TemplateNotFoundError(f"Template '{name}' not found")
289
+
290
+ if template.is_builtin:
291
+ raise TemplateError(f"Cannot remove built-in template '{name}'")
292
+
293
+ template_path = self.templates_dir / f"{name}.yaml"
294
+ template_path.unlink()
295
+
296
+ def get_template_path(self, name: str) -> Optional[Path]:
297
+ """Get the file path of a template"""
298
+ template_path = self.templates_dir / f"{name}.yaml"
299
+ if template_path.exists():
300
+ return template_path
301
+ return None
jfox/template_cli.py ADDED
@@ -0,0 +1,327 @@
1
+ """Template management CLI commands"""
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Optional, List
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from .config import config
13
+ from .template import TemplateManager, TemplateError, TemplateNotFoundError
14
+
15
+
16
+ console = Console()
17
+
18
+ # Create template subcommand app
19
+ template_app = typer.Typer(
20
+ name="template",
21
+ help="Manage note templates",
22
+ )
23
+
24
+
25
+ def get_template_manager() -> TemplateManager:
26
+ """Get template manager instance for current knowledge base"""
27
+ templates_dir = config.base_dir / ".zk" / "templates"
28
+ return TemplateManager(templates_dir)
29
+
30
+
31
+ @template_app.command("list")
32
+ def list_templates(
33
+ kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
34
+ output_format: str = typer.Option("table", "--format", "-f", help="输出格式: json, table"),
35
+ json_output: bool = typer.Option(False, "--json", help="JSON 输出(快捷方式,等同于 --format json)"),
36
+ ):
37
+ """列出所有可用模板"""
38
+ try:
39
+ from .config import use_kb
40
+
41
+ # 处理 --json 快捷方式
42
+ if json_output:
43
+ output_format = "json"
44
+
45
+ if kb:
46
+ with use_kb(kb):
47
+ manager = get_template_manager()
48
+ templates = manager.list_templates()
49
+ else:
50
+ manager = get_template_manager()
51
+ templates = manager.list_templates()
52
+
53
+ # Separate built-in and custom templates
54
+ builtin_templates = [t for t in templates if t.is_builtin]
55
+ custom_templates = [t for t in templates if not t.is_builtin]
56
+
57
+ if output_format == "json":
58
+ import json
59
+ result = {
60
+ "builtin": [
61
+ {
62
+ "name": t.name,
63
+ "description": t.description,
64
+ "note_type": t.note_type,
65
+ }
66
+ for t in builtin_templates
67
+ ],
68
+ "custom": [
69
+ {
70
+ "name": t.name,
71
+ "description": t.description,
72
+ "note_type": t.note_type,
73
+ }
74
+ for t in custom_templates
75
+ ],
76
+ }
77
+ print(json.dumps(result, ensure_ascii=False, indent=2))
78
+ elif output_format == "table":
79
+ if builtin_templates:
80
+ console.print("[bold]Built-in Templates:[/bold]")
81
+ table = Table()
82
+ table.add_column("Name", style="cyan")
83
+ table.add_column("Description", style="white")
84
+ table.add_column("Type", style="green")
85
+
86
+ for t in builtin_templates:
87
+ table.add_row(t.name, t.description, t.note_type)
88
+ console.print(table)
89
+ console.print()
90
+
91
+ if custom_templates:
92
+ console.print("[bold]Custom Templates:[/bold]")
93
+ table = Table()
94
+ table.add_column("Name", style="cyan")
95
+ table.add_column("Description", style="white")
96
+ table.add_column("Type", style="green")
97
+
98
+ for t in custom_templates:
99
+ table.add_row(t.name, t.description, t.note_type)
100
+ console.print(table)
101
+ elif not builtin_templates:
102
+ console.print("[dim]No templates found[/dim]")
103
+ else:
104
+ console.print(f"[red]Error:[/red] Unsupported format: {output_format}")
105
+ raise typer.Exit(1)
106
+
107
+ except Exception as e:
108
+ console.print(f"[red]Error: {e}[/red]")
109
+ raise typer.Exit(1)
110
+
111
+
112
+ @template_app.command("show")
113
+ def show_template(
114
+ name: str = typer.Argument(..., help="模板名称"),
115
+ kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
116
+ json_output: bool = typer.Option(True, "--json/--no-json", help="JSON 输出"),
117
+ ):
118
+ """查看模板详情"""
119
+ try:
120
+ from .config import use_kb
121
+
122
+ if kb:
123
+ with use_kb(kb):
124
+ manager = get_template_manager()
125
+ template = manager.get_template(name)
126
+ else:
127
+ manager = get_template_manager()
128
+ template = manager.get_template(name)
129
+
130
+ if not template:
131
+ available = manager.get_available_templates()
132
+ console.print(f"[red]Template '{name}' not found[/red]")
133
+ if available:
134
+ console.print(f"[dim]Available: {', '.join(available)}[/dim]")
135
+ raise typer.Exit(1)
136
+
137
+ if json_output:
138
+ import json
139
+ result = {
140
+ "name": template.name,
141
+ "description": template.description,
142
+ "note_type": template.note_type,
143
+ "title_format": template.title_format,
144
+ "content": template.content,
145
+ "tags": template.tags,
146
+ "is_builtin": template.is_builtin,
147
+ }
148
+ print(json.dumps(result, ensure_ascii=False, indent=2))
149
+ else:
150
+ builtin_tag = " (built-in)" if template.is_builtin else ""
151
+ console.print(f"[bold]{template.name}{builtin_tag}[/bold]")
152
+ console.print(f"[dim]{template.description}[/dim]")
153
+ console.print()
154
+ console.print(f"Type: {template.note_type}")
155
+ console.print(f"Title Format: {template.title_format}")
156
+ console.print(f"Tags: {', '.join(template.tags) if template.tags else 'none'}")
157
+ console.print()
158
+ console.print("[bold]Content:[/bold]")
159
+ console.print(template.content)
160
+
161
+ except Exception as e:
162
+ console.print(f"[red]Error: {e}[/red]")
163
+ raise typer.Exit(1)
164
+
165
+
166
+ @template_app.command("create")
167
+ def create_template(
168
+ name: str = typer.Argument(..., help="模板名称"),
169
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="模板描述"),
170
+ note_type: str = typer.Option("fleeting", "--type", "-t", help="笔记类型 (fleeting/literature/permanent)"),
171
+ title_format: str = typer.Option("{{date}}-{{title}}", "--title-format", help="标题格式"),
172
+ content: Optional[str] = typer.Option(None, "--content", "-c", help="模板内容"),
173
+ tags: Optional[List[str]] = typer.Option(None, "--tag", help="默认标签"),
174
+ kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
175
+ force: bool = typer.Option(False, "--force", "-f", help="覆盖已存在模板"),
176
+ json_output: bool = typer.Option(True, "--json/--no-json", help="JSON 输出"),
177
+ ):
178
+ """创建新模板"""
179
+ try:
180
+ from .config import use_kb
181
+
182
+ if kb:
183
+ with use_kb(kb):
184
+ manager = get_template_manager()
185
+ else:
186
+ manager = get_template_manager()
187
+
188
+ # Check if template exists
189
+ existing = manager.get_template(name)
190
+ if existing and not force:
191
+ console.print(f"[red]Template '{name}' already exists[/red]")
192
+ console.print("[dim]Use --force to overwrite[/dim]")
193
+ raise typer.Exit(1)
194
+
195
+ # Interactive mode if not all required fields provided
196
+ desc = description or typer.prompt("Description", default=f"Custom {name} template")
197
+ content_str = content or typer.prompt(
198
+ "Content (use {{variable}} for variables)",
199
+ default="## {{title}}\n\n{{content}}"
200
+ )
201
+
202
+ # Validate note_type
203
+ if note_type not in ["fleeting", "literature", "permanent"]:
204
+ console.print(f"[red]Invalid note type: {note_type}[/red]")
205
+ raise typer.Exit(1)
206
+
207
+ template = manager.create_template(
208
+ name=name,
209
+ description=desc,
210
+ note_type=note_type,
211
+ title_format=title_format,
212
+ content=content_str,
213
+ tags=tags or [],
214
+ )
215
+
216
+ if json_output:
217
+ import json
218
+ result = {
219
+ "success": True,
220
+ "template": {
221
+ "name": template.name,
222
+ "description": template.description,
223
+ "note_type": template.note_type,
224
+ },
225
+ }
226
+ print(json.dumps(result, ensure_ascii=False, indent=2))
227
+ else:
228
+ action = "updated" if existing else "created"
229
+ console.print(f"[green]Template '{name}' {action} successfully[/green]")
230
+
231
+ except Exception as e:
232
+ console.print(f"[red]Error: {e}[/red]")
233
+ raise typer.Exit(1)
234
+
235
+
236
+ @template_app.command("edit")
237
+ def edit_template(
238
+ name: str = typer.Argument(..., help="模板名称"),
239
+ kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
240
+ ):
241
+ """编辑模板(使用系统默认编辑器)"""
242
+ try:
243
+ from .config import use_kb
244
+
245
+ if kb:
246
+ with use_kb(kb):
247
+ manager = get_template_manager()
248
+ template = manager.get_template(name)
249
+ else:
250
+ manager = get_template_manager()
251
+ template = manager.get_template(name)
252
+
253
+ if not template:
254
+ console.print(f"[red]Template '{name}' not found[/red]")
255
+ raise typer.Exit(1)
256
+
257
+ if template.is_builtin:
258
+ console.print(f"[red]Cannot edit built-in template '{name}'[/red]")
259
+ console.print("[dim]Create a custom template instead[/dim]")
260
+ raise typer.Exit(1)
261
+
262
+ template_path = manager.get_template_path(name)
263
+ if not template_path:
264
+ console.print(f"[red]Template file not found[/red]")
265
+ raise typer.Exit(1)
266
+
267
+ # Get editor from environment
268
+ editor = os.environ.get("EDITOR", "notepad" if os.name == "nt" else "vi")
269
+
270
+ # Open editor
271
+ subprocess.run([editor, str(template_path)], check=True)
272
+ console.print(f"[green]Template '{name}' updated[/green]")
273
+
274
+ except subprocess.CalledProcessError as e:
275
+ console.print(f"[red]Editor failed: {e}[/red]")
276
+ raise typer.Exit(1)
277
+ except Exception as e:
278
+ console.print(f"[red]Error: {e}[/red]")
279
+ raise typer.Exit(1)
280
+
281
+
282
+ @template_app.command("remove")
283
+ def remove_template(
284
+ name: str = typer.Argument(..., help="模板名称"),
285
+ kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
286
+ yes: bool = typer.Option(False, "--yes", "-y", help="跳过确认"),
287
+ json_output: bool = typer.Option(True, "--json/--no-json", help="JSON 输出"),
288
+ ):
289
+ """删除自定义模板"""
290
+ try:
291
+ from .config import use_kb
292
+
293
+ if kb:
294
+ with use_kb(kb):
295
+ manager = get_template_manager()
296
+ else:
297
+ manager = get_template_manager()
298
+
299
+ template = manager.get_template(name)
300
+ if not template:
301
+ console.print(f"[red]Template '{name}' not found[/red]")
302
+ raise typer.Exit(1)
303
+
304
+ if template.is_builtin:
305
+ console.print(f"[red]Cannot remove built-in template '{name}'[/red]")
306
+ raise typer.Exit(1)
307
+
308
+ # Confirm deletion
309
+ if not yes:
310
+ if not json_output:
311
+ confirm = input(f"Delete template '{name}'? (y/N): ")
312
+ if confirm.lower() != "y":
313
+ console.print("Cancelled")
314
+ raise typer.Exit(0)
315
+
316
+ manager.delete_template(name)
317
+
318
+ if json_output:
319
+ import json
320
+ result = {"success": True, "deleted": name}
321
+ print(json.dumps(result, ensure_ascii=False, indent=2))
322
+ else:
323
+ console.print(f"[green]Template '{name}' deleted[/green]")
324
+
325
+ except Exception as e:
326
+ console.print(f"[red]Error: {e}[/red]")
327
+ raise typer.Exit(1)