jfox-cli 0.1.3__tar.gz → 0.1.4__tar.gz
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_cli-0.1.3 → jfox_cli-0.1.4}/CLAUDE.md +1 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/PKG-INFO +1 -1
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/__init__.py +1 -1
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/bm25_index.py +72 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/cli.py +237 -37
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/note.py +78 -4
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/performance.py +7 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/pyproject.toml +1 -1
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_cli_format.py +124 -0
- jfox_cli-0.1.4/tests/unit/test_bm25_batch.py +263 -0
- jfox_cli-0.1.4/tests/unit/test_edit.py +389 -0
- jfox_cli-0.1.4/tests/unit/test_format_unify.py +301 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/utils/jfox_cli.py +44 -4
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/uv.lock +1 -1
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/.githooks/pre-push +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/.github/workflows/integration-test.yml +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/.github/workflows/publish.yml +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/.gitignore +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/.python-version +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/AGENTS.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/DEVELOPMENT_PLAN.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/README.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/SESSION.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/SESSION_SUMMARY.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/docs/superpowers/specs/2026-04-03-bugfixes-design.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jessica-jones-static-cable.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/__main__.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/config.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/embedding_backend.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/formatters.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/global_config.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/graph.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/indexer.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/kb_manager.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/models.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/search_engine.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/template.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/template_cli.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/vector_store.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/pytest.ini +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/run_full_test.ps1 +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skill/evals/evals.json +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skill/knowledge-base-notes/SKILL.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skill/knowledge-base-workspace/SKILL.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skills-recommend/README.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-health/SKILL.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-init/SKILL.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-insert/SKILL.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-organize/SKILL.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-search/SKILL.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/COVERAGE_PLAN.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/MIGRATION.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/TESTS.md +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/conftest.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/integration/__init__.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/integration/test_backlinks.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/performance/__init__.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/performance/test_performance.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_advanced_features.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_config_unit.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_core_workflow.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_hybrid_search.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_integration.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_kb_current.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_suggest_links.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/unit/__init__.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/unit/test_formatters.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/unit/test_global_config.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/unit/test_kb_manager.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/unit/test_template.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/unit/test_template_cli.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/utils/__init__.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/utils/assertions.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/utils/note_generator.py +0 -0
- {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/utils/temp_kb.py +0 -0
|
@@ -91,6 +91,7 @@ Notes are Markdown files with YAML frontmatter stored under `~/.zettelkasten/<kb
|
|
|
91
91
|
|
|
92
92
|
## Conventions
|
|
93
93
|
|
|
94
|
+
- **Version bump**: 发版时必须同时修改 `pyproject.toml` 和 `jfox/__init__.py` 两处版本号(曾有 #88 遗漏 `__init__.py` 的教训)
|
|
94
95
|
- **Line length**: 100 chars (black + ruff configured in `pyproject.toml`)
|
|
95
96
|
- **Comments/docs**: Chinese (中文)
|
|
96
97
|
- **Adding a CLI command**: Add `@app.command()` in `cli.py`, implement `_xxx_impl()` helper, add `--kb` and `--format json` support
|
|
@@ -249,6 +249,78 @@ class BM25Index:
|
|
|
249
249
|
logger.error(f"Failed to remove document {note_id}: {e}")
|
|
250
250
|
return False
|
|
251
251
|
|
|
252
|
+
def add_documents_batch(self, documents: List[Tuple[str, str]]) -> bool:
|
|
253
|
+
"""
|
|
254
|
+
批量添加文档到索引(高效版本)
|
|
255
|
+
|
|
256
|
+
与逐条调用 add_document() 不同,此方法收集所有文档后只执行一次索引重建和保存。
|
|
257
|
+
适用于批量导入场景。
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
documents: [(note_id, content), ...] 列表
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
是否成功添加
|
|
264
|
+
"""
|
|
265
|
+
if not documents:
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
# 快照当前状态,失败时恢复
|
|
269
|
+
saved_docs = list(self.documents)
|
|
270
|
+
saved_ids = list(self.doc_ids)
|
|
271
|
+
saved_mapping = dict(self.doc_mapping)
|
|
272
|
+
saved_bm25 = self.bm25
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
for note_id, content in documents:
|
|
276
|
+
# 如果已存在,先移除
|
|
277
|
+
if note_id in self.doc_mapping:
|
|
278
|
+
# 内联移除逻辑,避免触发 rebuild/save
|
|
279
|
+
idx = self.doc_mapping[note_id]
|
|
280
|
+
self.documents.pop(idx)
|
|
281
|
+
self.doc_ids.pop(idx)
|
|
282
|
+
del self.doc_mapping[note_id]
|
|
283
|
+
# 更新后续索引
|
|
284
|
+
self.doc_mapping = {}
|
|
285
|
+
for i, doc_id in enumerate(self.doc_ids):
|
|
286
|
+
self.doc_mapping[doc_id] = i
|
|
287
|
+
|
|
288
|
+
# 分词并添加
|
|
289
|
+
tokens = self._tokenize(content)
|
|
290
|
+
if not tokens:
|
|
291
|
+
continue # 跳过分词结果为空的文档
|
|
292
|
+
idx = len(self.documents)
|
|
293
|
+
self.documents.append(tokens)
|
|
294
|
+
self.doc_ids.append(note_id)
|
|
295
|
+
self.doc_mapping[note_id] = idx
|
|
296
|
+
|
|
297
|
+
# 一次性重建索引
|
|
298
|
+
self._rebuild_index()
|
|
299
|
+
|
|
300
|
+
# 一次性保存,失败时回滚
|
|
301
|
+
if not self._save():
|
|
302
|
+
self.documents = saved_docs
|
|
303
|
+
self.doc_ids = saved_ids
|
|
304
|
+
self.doc_mapping = saved_mapping
|
|
305
|
+
self.bm25 = saved_bm25
|
|
306
|
+
logger.error("Failed to persist BM25 index after batch add, rolled back")
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
logger.info(f"Batch added {len(documents)} documents to BM25 index")
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
except Exception as e:
|
|
313
|
+
# 恢复到批次前的状态
|
|
314
|
+
self.documents = saved_docs
|
|
315
|
+
self.doc_ids = saved_ids
|
|
316
|
+
self.doc_mapping = saved_mapping
|
|
317
|
+
self.bm25 = saved_bm25
|
|
318
|
+
logger.error(
|
|
319
|
+
f"Failed to batch add {len(documents)} documents to BM25 index",
|
|
320
|
+
exc_info=True,
|
|
321
|
+
)
|
|
322
|
+
return False
|
|
323
|
+
|
|
252
324
|
def search(self, query: str, top_k: int = 5) -> List[Dict]:
|
|
253
325
|
"""
|
|
254
326
|
搜索文档
|
|
@@ -90,7 +90,8 @@ def init(
|
|
|
90
90
|
path: Optional[str] = typer.Option(None, "--path", "-p", help="知识库路径(默认: ~/.zettelkasten/<name>/)"),
|
|
91
91
|
description: Optional[str] = typer.Option(None, "--desc", "-d", help="知识库描述"),
|
|
92
92
|
set_default: bool = typer.Option(True, "--default/--no-default", help="设为默认知识库"),
|
|
93
|
-
|
|
93
|
+
output_format: str = typer.Option("table", "--format", "-f", help="输出格式: json, table"),
|
|
94
|
+
json_output: bool = typer.Option(False, "--json", help="JSON 输出(快捷方式,等同于 --format json)"),
|
|
94
95
|
):
|
|
95
96
|
"""
|
|
96
97
|
初始化知识库
|
|
@@ -103,6 +104,10 @@ def init(
|
|
|
103
104
|
jfox init --name personal --desc "个人笔记"
|
|
104
105
|
"""
|
|
105
106
|
try:
|
|
107
|
+
# 向后兼容:--json 快捷方式
|
|
108
|
+
if json_output:
|
|
109
|
+
output_format = "json"
|
|
110
|
+
|
|
106
111
|
kb_name = name or "default"
|
|
107
112
|
manager = get_kb_manager()
|
|
108
113
|
|
|
@@ -112,7 +117,7 @@ def init(
|
|
|
112
117
|
"success": False,
|
|
113
118
|
"error": f"Knowledge base '{kb_name}' already exists. Use 'jfox kb list' to see all knowledge bases.",
|
|
114
119
|
}
|
|
115
|
-
if
|
|
120
|
+
if output_format == "json":
|
|
116
121
|
print(output_json(result))
|
|
117
122
|
else:
|
|
118
123
|
console.print(f"[red]✗[/red] Knowledge base '{kb_name}' already exists")
|
|
@@ -137,7 +142,7 @@ def init(
|
|
|
137
142
|
f"'{kb_root}'. All knowledge bases must be under {kb_root}/"
|
|
138
143
|
),
|
|
139
144
|
}
|
|
140
|
-
if
|
|
145
|
+
if output_format == "json":
|
|
141
146
|
print(output_json(result))
|
|
142
147
|
else:
|
|
143
148
|
console.print(f"[red]✗[/red] {result['error']}")
|
|
@@ -157,36 +162,50 @@ def init(
|
|
|
157
162
|
"message": message,
|
|
158
163
|
"name": kb_name,
|
|
159
164
|
}
|
|
160
|
-
|
|
161
|
-
if
|
|
165
|
+
|
|
166
|
+
if output_format == "json":
|
|
162
167
|
print(output_json(result))
|
|
163
168
|
else:
|
|
164
|
-
|
|
169
|
+
_print_action_table("init", {
|
|
170
|
+
"KB": kb_name,
|
|
171
|
+
})
|
|
165
172
|
if set_default:
|
|
166
|
-
console.print(f"[dim]This is now your default knowledge base[/dim]")
|
|
173
|
+
console.print(f"[dim] This is now your default knowledge base[/dim]")
|
|
167
174
|
else:
|
|
168
175
|
result = {
|
|
169
176
|
"success": False,
|
|
170
177
|
"error": message,
|
|
171
178
|
}
|
|
172
|
-
if
|
|
179
|
+
if output_format == "json":
|
|
173
180
|
print(output_json(result))
|
|
174
181
|
else:
|
|
175
182
|
console.print(f"[red]✗[/red] {message}")
|
|
176
183
|
raise typer.Exit(1)
|
|
177
184
|
|
|
185
|
+
except typer.Exit:
|
|
186
|
+
raise
|
|
178
187
|
except Exception as e:
|
|
179
188
|
result = {
|
|
180
189
|
"success": False,
|
|
181
190
|
"error": str(e),
|
|
182
191
|
}
|
|
183
|
-
if
|
|
192
|
+
if output_format == "json":
|
|
184
193
|
print(output_json(result))
|
|
185
194
|
else:
|
|
186
195
|
console.print(f"[red]✗[/red] Error: {e}")
|
|
187
196
|
raise typer.Exit(1)
|
|
188
197
|
|
|
189
198
|
|
|
199
|
+
def _print_action_table(action: str, fields: dict):
|
|
200
|
+
"""打印紧凑的操作结果表格(单行)"""
|
|
201
|
+
table = Table(show_header=True, box=None, padding=(0, 2))
|
|
202
|
+
table.add_column("Action", style="green")
|
|
203
|
+
for key in fields:
|
|
204
|
+
table.add_column(key)
|
|
205
|
+
table.add_row(action, *[str(v) for v in fields.values()])
|
|
206
|
+
console.print(table)
|
|
207
|
+
|
|
208
|
+
|
|
190
209
|
def extract_wiki_links(content: str) -> List[str]:
|
|
191
210
|
"""从内容中提取 [[...]] 格式的维基链接"""
|
|
192
211
|
import re
|
|
@@ -226,7 +245,7 @@ def _add_note_impl(
|
|
|
226
245
|
note_type: str,
|
|
227
246
|
tags: Optional[List[str]],
|
|
228
247
|
source: Optional[str],
|
|
229
|
-
|
|
248
|
+
output_format: str,
|
|
230
249
|
template: Optional[str] = None,
|
|
231
250
|
):
|
|
232
251
|
"""添加笔记的内部实现"""
|
|
@@ -318,20 +337,21 @@ def _add_note_impl(
|
|
|
318
337
|
"links": resolved_links,
|
|
319
338
|
},
|
|
320
339
|
}
|
|
321
|
-
|
|
340
|
+
|
|
322
341
|
if unresolved:
|
|
323
342
|
result["warnings"] = f"Unresolved links: {', '.join(unresolved)}"
|
|
324
|
-
|
|
325
|
-
if
|
|
343
|
+
|
|
344
|
+
if output_format == "json":
|
|
326
345
|
print(output_json(result))
|
|
327
346
|
else:
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
347
|
+
_print_action_table("created", {
|
|
348
|
+
"ID": new_note.id,
|
|
349
|
+
"Title": new_note.title,
|
|
350
|
+
"Type": new_note.type.value,
|
|
351
|
+
"Links": str(len(resolved_links)),
|
|
352
|
+
})
|
|
333
353
|
if backlink_updated > 0:
|
|
334
|
-
console.print(f" Backlinks updated: {backlink_updated} note(s)")
|
|
354
|
+
console.print(f"[dim] Backlinks updated: {backlink_updated} note(s)[/dim]")
|
|
335
355
|
if unresolved:
|
|
336
356
|
console.print(f" [yellow]Warning: Unresolved links - {', '.join(unresolved)}[/yellow]")
|
|
337
357
|
else:
|
|
@@ -347,24 +367,31 @@ def add(
|
|
|
347
367
|
source: Optional[str] = typer.Option(None, "--source", "-s", help="来源(文献笔记)"),
|
|
348
368
|
template: Optional[str] = typer.Option(None, "--template", "-T", help="使用模板创建笔记 (quick/meeting/literature)"),
|
|
349
369
|
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
350
|
-
|
|
370
|
+
output_format: str = typer.Option("table", "--format", "-f", help="输出格式: json, table"),
|
|
371
|
+
json_output: bool = typer.Option(False, "--json", help="JSON 输出(快捷方式,等同于 --format json)"),
|
|
351
372
|
):
|
|
352
373
|
"""添加新笔记(内容中可用 [[笔记标题]] 引用其他笔记)"""
|
|
353
374
|
try:
|
|
375
|
+
# 向后兼容:--json 快捷方式
|
|
376
|
+
if json_output:
|
|
377
|
+
output_format = "json"
|
|
378
|
+
|
|
354
379
|
# 如果指定了知识库,临时切换
|
|
355
380
|
if kb:
|
|
356
381
|
from .config import use_kb
|
|
357
382
|
with use_kb(kb):
|
|
358
|
-
_add_note_impl(content, title, note_type, tags, source,
|
|
383
|
+
_add_note_impl(content, title, note_type, tags, source, output_format, template)
|
|
359
384
|
else:
|
|
360
|
-
_add_note_impl(content, title, note_type, tags, source,
|
|
361
|
-
|
|
385
|
+
_add_note_impl(content, title, note_type, tags, source, output_format, template)
|
|
386
|
+
|
|
387
|
+
except typer.Exit:
|
|
388
|
+
raise
|
|
362
389
|
except Exception as e:
|
|
363
390
|
result = {
|
|
364
391
|
"success": False,
|
|
365
392
|
"error": str(e),
|
|
366
393
|
}
|
|
367
|
-
if
|
|
394
|
+
if output_format == "json":
|
|
368
395
|
print(output_json(result))
|
|
369
396
|
else:
|
|
370
397
|
console.print(f"[red]✗[/red] Error: {e}")
|
|
@@ -824,18 +851,17 @@ def refs(
|
|
|
824
851
|
def _delete_impl(
|
|
825
852
|
note_id: str,
|
|
826
853
|
force: bool,
|
|
827
|
-
|
|
854
|
+
output_format: str,
|
|
828
855
|
):
|
|
829
856
|
"""删除笔记的内部实现"""
|
|
830
857
|
# 先查找笔记
|
|
831
858
|
n = note.load_note_by_id(note_id)
|
|
832
859
|
if not n:
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
860
|
+
raise ValueError(f"Note not found: {note_id}")
|
|
861
|
+
|
|
836
862
|
# 确认删除
|
|
837
863
|
if not force:
|
|
838
|
-
if
|
|
864
|
+
if output_format == "json":
|
|
839
865
|
console.print(f"Use --force to delete: {n.title}")
|
|
840
866
|
raise typer.Exit(1)
|
|
841
867
|
else:
|
|
@@ -844,7 +870,7 @@ def _delete_impl(
|
|
|
844
870
|
if confirm.lower() != "y":
|
|
845
871
|
console.print("Cancelled")
|
|
846
872
|
raise typer.Exit(0)
|
|
847
|
-
|
|
873
|
+
|
|
848
874
|
# 执行删除
|
|
849
875
|
if note.delete_note(note_id):
|
|
850
876
|
result = {
|
|
@@ -852,11 +878,14 @@ def _delete_impl(
|
|
|
852
878
|
"deleted": note_id,
|
|
853
879
|
"title": n.title,
|
|
854
880
|
}
|
|
855
|
-
|
|
856
|
-
if
|
|
881
|
+
|
|
882
|
+
if output_format == "json":
|
|
857
883
|
print(output_json(result))
|
|
858
884
|
else:
|
|
859
|
-
|
|
885
|
+
_print_action_table("deleted", {
|
|
886
|
+
"ID": note_id,
|
|
887
|
+
"Title": n.title,
|
|
888
|
+
})
|
|
860
889
|
else:
|
|
861
890
|
raise Exception("Failed to delete note")
|
|
862
891
|
|
|
@@ -866,24 +895,195 @@ def delete(
|
|
|
866
895
|
note_id: str = typer.Argument(..., help="笔记 ID"),
|
|
867
896
|
force: bool = typer.Option(False, "--force", "-f", help="强制删除不确认"),
|
|
868
897
|
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
869
|
-
|
|
898
|
+
output_format: str = typer.Option("table", "--format", help="输出格式: json, table"),
|
|
899
|
+
json_output: bool = typer.Option(False, "--json", help="JSON 输出(快捷方式,等同于 --format json)"),
|
|
870
900
|
):
|
|
871
901
|
"""删除笔记"""
|
|
872
902
|
try:
|
|
903
|
+
# 向后兼容:--json 快捷方式
|
|
904
|
+
if json_output:
|
|
905
|
+
output_format = "json"
|
|
906
|
+
|
|
873
907
|
# 如果指定了知识库,临时切换
|
|
874
908
|
if kb:
|
|
875
909
|
from .config import use_kb
|
|
876
910
|
with use_kb(kb):
|
|
877
|
-
_delete_impl(note_id, force,
|
|
911
|
+
_delete_impl(note_id, force, output_format)
|
|
878
912
|
else:
|
|
879
|
-
_delete_impl(note_id, force,
|
|
880
|
-
|
|
913
|
+
_delete_impl(note_id, force, output_format)
|
|
914
|
+
|
|
915
|
+
except typer.Exit:
|
|
916
|
+
raise
|
|
881
917
|
except Exception as e:
|
|
882
918
|
result = {
|
|
883
919
|
"success": False,
|
|
884
920
|
"error": str(e),
|
|
885
921
|
}
|
|
922
|
+
if output_format == "json":
|
|
923
|
+
print(output_json(result))
|
|
924
|
+
else:
|
|
925
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
926
|
+
raise typer.Exit(1)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _edit_impl(
|
|
930
|
+
note_id: str,
|
|
931
|
+
content: Optional[str],
|
|
932
|
+
title: Optional[str],
|
|
933
|
+
tags: Optional[List[str]],
|
|
934
|
+
note_type: Optional[str],
|
|
935
|
+
source: Optional[str],
|
|
936
|
+
output_format: str,
|
|
937
|
+
):
|
|
938
|
+
"""编辑笔记的内部实现"""
|
|
939
|
+
# 验证:至少指定一个编辑字段
|
|
940
|
+
if all(v is None for v in [content, title, tags, note_type, source]):
|
|
941
|
+
raise ValueError("至少指定一个要编辑的字段 (--content, --title, --tags, --type, --source)")
|
|
942
|
+
|
|
943
|
+
# 加载笔记
|
|
944
|
+
n = note.load_note_by_id(note_id)
|
|
945
|
+
if not n:
|
|
946
|
+
raise ValueError(f"笔记不存在: {note_id}")
|
|
947
|
+
|
|
948
|
+
old_title = n.title
|
|
949
|
+
old_links = set(n.links)
|
|
950
|
+
|
|
951
|
+
# 更新字段
|
|
952
|
+
if content is not None:
|
|
953
|
+
n.content = content
|
|
954
|
+
if title is not None:
|
|
955
|
+
n.title = title
|
|
956
|
+
if tags is not None:
|
|
957
|
+
n.tags = tags
|
|
958
|
+
if source is not None:
|
|
959
|
+
n.source = source if source else None # 空字符串清除 source
|
|
960
|
+
if note_type is not None:
|
|
961
|
+
try:
|
|
962
|
+
new_type = NoteType(note_type.lower())
|
|
963
|
+
except ValueError:
|
|
964
|
+
raise ValueError(
|
|
965
|
+
f"Invalid note type: {note_type}. Use: fleeting, literature, permanent"
|
|
966
|
+
)
|
|
967
|
+
n.type = new_type
|
|
968
|
+
|
|
969
|
+
# 如果内容被更新,解析 wiki links
|
|
970
|
+
if content is not None:
|
|
971
|
+
wiki_links = extract_wiki_links(content)
|
|
972
|
+
resolved_links = []
|
|
973
|
+
unresolved = []
|
|
974
|
+
|
|
975
|
+
all_notes = note.list_notes() if wiki_links else []
|
|
976
|
+
for link_text in wiki_links:
|
|
977
|
+
target_id = find_note_id_by_title_or_id(link_text, all_notes=all_notes)
|
|
978
|
+
if target_id:
|
|
979
|
+
resolved_links.append(target_id)
|
|
980
|
+
else:
|
|
981
|
+
unresolved.append(link_text)
|
|
982
|
+
|
|
983
|
+
n.links = resolved_links
|
|
984
|
+
else:
|
|
985
|
+
unresolved = []
|
|
986
|
+
|
|
987
|
+
# 保存更新
|
|
988
|
+
if note.update_note(n):
|
|
989
|
+
# 更新反向链接
|
|
990
|
+
new_links = set(n.links)
|
|
991
|
+
|
|
992
|
+
# 新增的链接 → 添加反向链接
|
|
993
|
+
added_links = new_links - old_links
|
|
994
|
+
for target_id in added_links:
|
|
995
|
+
target_note = note.load_note_by_id(target_id)
|
|
996
|
+
if target_note and n.id not in target_note.backlinks:
|
|
997
|
+
target_note.backlinks.append(n.id)
|
|
998
|
+
note.save_note(target_note, add_to_index=False)
|
|
999
|
+
|
|
1000
|
+
# 移除的链接 → 删除反向链接
|
|
1001
|
+
removed_links = old_links - new_links
|
|
1002
|
+
for target_id in removed_links:
|
|
1003
|
+
target_note = note.load_note_by_id(target_id)
|
|
1004
|
+
if target_note and n.id in target_note.backlinks:
|
|
1005
|
+
target_note.backlinks.remove(n.id)
|
|
1006
|
+
note.save_note(target_note, add_to_index=False)
|
|
1007
|
+
|
|
1008
|
+
result = {
|
|
1009
|
+
"success": True,
|
|
1010
|
+
"note": {
|
|
1011
|
+
"id": n.id,
|
|
1012
|
+
"title": n.title,
|
|
1013
|
+
"type": n.type.value,
|
|
1014
|
+
"filepath": str(n.filepath),
|
|
1015
|
+
},
|
|
1016
|
+
}
|
|
1017
|
+
if old_title != n.title:
|
|
1018
|
+
result["title_changed"] = {"old": old_title, "new": n.title}
|
|
1019
|
+
if unresolved:
|
|
1020
|
+
result["warnings"] = f"Unresolved links: {', '.join(unresolved)}"
|
|
1021
|
+
|
|
1022
|
+
if output_format == "json":
|
|
1023
|
+
print(output_json(result))
|
|
1024
|
+
else:
|
|
1025
|
+
# 收集修改的字段名
|
|
1026
|
+
changed = []
|
|
1027
|
+
if content is not None:
|
|
1028
|
+
changed.append("content")
|
|
1029
|
+
if title is not None:
|
|
1030
|
+
changed.append("title")
|
|
1031
|
+
if tags is not None:
|
|
1032
|
+
changed.append("tags")
|
|
1033
|
+
if note_type is not None:
|
|
1034
|
+
changed.append("type")
|
|
1035
|
+
if source is not None:
|
|
1036
|
+
changed.append("source")
|
|
1037
|
+
_print_action_table("updated", {
|
|
1038
|
+
"ID": n.id,
|
|
1039
|
+
"Title": n.title,
|
|
1040
|
+
"Fields": ", ".join(changed),
|
|
1041
|
+
})
|
|
1042
|
+
if old_title != n.title:
|
|
1043
|
+
console.print(f" [dim]Title: {old_title} → {n.title}[/dim]")
|
|
1044
|
+
if unresolved:
|
|
1045
|
+
console.print(
|
|
1046
|
+
f" [yellow]Warning: Unresolved links - {', '.join(unresolved)}[/yellow]"
|
|
1047
|
+
)
|
|
1048
|
+
else:
|
|
1049
|
+
raise Exception("Failed to update note")
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
@app.command()
|
|
1053
|
+
def edit(
|
|
1054
|
+
note_id: str = typer.Argument(..., help="笔记 ID"),
|
|
1055
|
+
content: Optional[str] = typer.Option(None, "--content", "-c", help="新内容"),
|
|
1056
|
+
title: Optional[str] = typer.Option(None, "--title", "-t", help="新标题"),
|
|
1057
|
+
tags: Optional[List[str]] = typer.Option(None, "--tag", help="新标签(替换全部)"),
|
|
1058
|
+
note_type: Optional[str] = typer.Option(
|
|
1059
|
+
None, "--type", help="新类型 (fleeting/literature/permanent)"
|
|
1060
|
+
),
|
|
1061
|
+
source: Optional[str] = typer.Option(None, "--source", "-s", help="新来源"),
|
|
1062
|
+
kb: Optional[str] = typer.Option(None, "--kb", "-k", help="目标知识库名称"),
|
|
1063
|
+
output_format: str = typer.Option("table", "--format", "-f", help="输出格式: json, table"),
|
|
1064
|
+
json_output: bool = typer.Option(False, "--json", help="JSON 输出(快捷方式,等同于 --format json)"),
|
|
1065
|
+
):
|
|
1066
|
+
"""编辑已有笔记(保留 ID 和创建时间)"""
|
|
1067
|
+
try:
|
|
1068
|
+
# 向后兼容:--json 快捷方式
|
|
886
1069
|
if json_output:
|
|
1070
|
+
output_format = "json"
|
|
1071
|
+
|
|
1072
|
+
if kb:
|
|
1073
|
+
from .config import use_kb
|
|
1074
|
+
|
|
1075
|
+
with use_kb(kb):
|
|
1076
|
+
_edit_impl(note_id, content, title, tags, note_type, source, output_format)
|
|
1077
|
+
else:
|
|
1078
|
+
_edit_impl(note_id, content, title, tags, note_type, source, output_format)
|
|
1079
|
+
except typer.Exit:
|
|
1080
|
+
raise
|
|
1081
|
+
except Exception as e:
|
|
1082
|
+
result = {
|
|
1083
|
+
"success": False,
|
|
1084
|
+
"error": str(e),
|
|
1085
|
+
}
|
|
1086
|
+
if output_format == "json":
|
|
887
1087
|
print(output_json(result))
|
|
888
1088
|
else:
|
|
889
1089
|
console.print(f"[red]✗[/red] Error: {e}")
|
|
@@ -116,15 +116,22 @@ def load_note_by_id(note_id: str, cfg: Optional[ZKConfig] = None) -> Optional[No
|
|
|
116
116
|
Note 对象或 None
|
|
117
117
|
"""
|
|
118
118
|
use_config = cfg or config
|
|
119
|
-
|
|
119
|
+
|
|
120
120
|
# 在所有类型目录中搜索
|
|
121
121
|
for note_type in NoteType:
|
|
122
122
|
dir_path = use_config.notes_dir / note_type.value
|
|
123
123
|
if not dir_path.exists():
|
|
124
124
|
continue
|
|
125
|
-
|
|
125
|
+
|
|
126
|
+
# 尝试两种文件名模式:
|
|
127
|
+
# 1. {id}*.md — literature/permanent 笔记({id}-{slug}.md)
|
|
128
|
+
# 2. {id[:8]}-{id[8:]}*.md — fleeting 笔记(YYYYMMDD-HHMMSSNNNN.md)
|
|
126
129
|
for filepath in dir_path.glob(f"{note_id}*.md"):
|
|
127
130
|
return load_note(filepath)
|
|
131
|
+
# fleeting 笔记文件名格式:YYYYMMDD-HHMMSSNNNN.md
|
|
132
|
+
if len(note_id) > 8:
|
|
133
|
+
for filepath in dir_path.glob(f"{note_id[:8]}-{note_id[8:]}*.md"):
|
|
134
|
+
return load_note(filepath)
|
|
128
135
|
|
|
129
136
|
return None
|
|
130
137
|
|
|
@@ -200,6 +207,67 @@ def delete_note(note_id: str) -> bool:
|
|
|
200
207
|
return False
|
|
201
208
|
|
|
202
209
|
|
|
210
|
+
def update_note(note_obj: Note, add_to_index: bool = True) -> bool:
|
|
211
|
+
"""
|
|
212
|
+
更新已有笔记
|
|
213
|
+
|
|
214
|
+
处理:查找旧文件 → 更新 updated 时间戳 → 写入新文件 → 删除旧文件(如路径变化)→ 更新索引
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
note_obj: 已修改的 Note 对象(必须已有 id)
|
|
218
|
+
add_to_index: 是否更新搜索索引
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
是否更新成功
|
|
222
|
+
"""
|
|
223
|
+
# 查找当前文件路径(可能标题改了,按 ID 查)
|
|
224
|
+
old_filepath = find_note_file(config, note_obj.id)
|
|
225
|
+
if not old_filepath:
|
|
226
|
+
logger.warning(f"Note {note_obj.id} file not found on disk")
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
# 更新时间戳
|
|
231
|
+
note_obj.updated = datetime.now()
|
|
232
|
+
|
|
233
|
+
# 写入新文件(filepath 属性根据当前字段生成)
|
|
234
|
+
note_obj.filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
with open(note_obj.filepath, 'w', encoding='utf-8') as f:
|
|
236
|
+
f.write(note_obj.to_markdown())
|
|
237
|
+
|
|
238
|
+
# 如果文件路径变了(标题修改导致重命名),删除旧文件
|
|
239
|
+
if old_filepath != note_obj.filepath and old_filepath.exists():
|
|
240
|
+
old_filepath.unlink()
|
|
241
|
+
logger.info(f"Renamed note file: {old_filepath} -> {note_obj.filepath}")
|
|
242
|
+
|
|
243
|
+
logger.info(f"Updated note {note_obj.id}")
|
|
244
|
+
|
|
245
|
+
# 更新索引
|
|
246
|
+
if add_to_index:
|
|
247
|
+
# 先删除旧索引,再添加新索引
|
|
248
|
+
try:
|
|
249
|
+
vector_store = get_vector_store()
|
|
250
|
+
vector_store.delete_note(note_obj.id)
|
|
251
|
+
vector_store.add_note(note_obj)
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.warning(f"Failed to update vector store index: {e}")
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
from .bm25_index import get_bm25_index
|
|
257
|
+
bm25_index = get_bm25_index()
|
|
258
|
+
bm25_index.remove_document(note_obj.id)
|
|
259
|
+
content = f"{note_obj.title} {note_obj.content}"
|
|
260
|
+
bm25_index.add_document(note_obj.id, content)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.warning(f"Failed to update BM25 index: {e}")
|
|
263
|
+
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.error(f"Failed to update note {note_obj.id}: {e}")
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
|
|
203
271
|
def get_stats(cfg: Optional[ZKConfig] = None) -> Dict[str, Any]:
|
|
204
272
|
"""
|
|
205
273
|
获取知识库统计
|
|
@@ -430,10 +498,16 @@ def find_note_file(config_obj, note_id: str) -> Optional[Path]:
|
|
|
430
498
|
dir_path = config_obj.notes_dir / note_type.value
|
|
431
499
|
if not dir_path.exists():
|
|
432
500
|
continue
|
|
433
|
-
|
|
501
|
+
|
|
502
|
+
# 尝试两种文件名模式:
|
|
503
|
+
# 1. {id}*.md — literature/permanent 笔记({id}-{slug}.md)
|
|
504
|
+
# 2. {id[:8]}-{id[8:]}*.md — fleeting 笔记(YYYYMMDD-HHMMSSNNNN.md)
|
|
434
505
|
for filepath in dir_path.glob(f"{note_id}*.md"):
|
|
435
506
|
return filepath
|
|
436
|
-
|
|
507
|
+
if len(note_id) > 8:
|
|
508
|
+
for filepath in dir_path.glob(f"{note_id[:8]}-{note_id[8:]}*.md"):
|
|
509
|
+
return filepath
|
|
510
|
+
|
|
437
511
|
return None
|
|
438
512
|
|
|
439
513
|
|
|
@@ -188,6 +188,7 @@ def bulk_import_notes(
|
|
|
188
188
|
from . import note as note_module
|
|
189
189
|
from .embedding_backend import get_backend
|
|
190
190
|
from .vector_store import get_vector_store
|
|
191
|
+
from .bm25_index import get_bm25_index
|
|
191
192
|
|
|
192
193
|
nt = NoteType(note_type.lower())
|
|
193
194
|
backend = get_backend()
|
|
@@ -264,6 +265,12 @@ def bulk_import_notes(
|
|
|
264
265
|
embeddings=embeddings,
|
|
265
266
|
metadatas=metadatas
|
|
266
267
|
)
|
|
268
|
+
|
|
269
|
+
# 批量添加到 BM25 索引
|
|
270
|
+
bm25 = get_bm25_index()
|
|
271
|
+
bm25_docs = [(n.id, f"{n.title} {n.content}") for n in notes]
|
|
272
|
+
bm25.add_documents_batch(bm25_docs)
|
|
273
|
+
|
|
267
274
|
except Exception as e:
|
|
268
275
|
logger.warning(f"Failed to index batch: {e}")
|
|
269
276
|
|