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.
Files changed (75) hide show
  1. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/CLAUDE.md +1 -0
  2. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/PKG-INFO +1 -1
  3. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/__init__.py +1 -1
  4. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/bm25_index.py +72 -0
  5. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/cli.py +237 -37
  6. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/note.py +78 -4
  7. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/performance.py +7 -0
  8. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/pyproject.toml +1 -1
  9. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_cli_format.py +124 -0
  10. jfox_cli-0.1.4/tests/unit/test_bm25_batch.py +263 -0
  11. jfox_cli-0.1.4/tests/unit/test_edit.py +389 -0
  12. jfox_cli-0.1.4/tests/unit/test_format_unify.py +301 -0
  13. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/utils/jfox_cli.py +44 -4
  14. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/uv.lock +1 -1
  15. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/.githooks/pre-push +0 -0
  16. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/.github/workflows/integration-test.yml +0 -0
  17. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/.github/workflows/publish.yml +0 -0
  18. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/.gitignore +0 -0
  19. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/.python-version +0 -0
  20. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/AGENTS.md +0 -0
  21. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/DEVELOPMENT_PLAN.md +0 -0
  22. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/README.md +0 -0
  23. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/SESSION.md +0 -0
  24. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/SESSION_SUMMARY.md +0 -0
  25. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/docs/superpowers/specs/2026-04-03-bugfixes-design.md +0 -0
  26. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jessica-jones-static-cable.md +0 -0
  27. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/__main__.py +0 -0
  28. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/config.py +0 -0
  29. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/embedding_backend.py +0 -0
  30. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/formatters.py +0 -0
  31. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/global_config.py +0 -0
  32. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/graph.py +0 -0
  33. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/indexer.py +0 -0
  34. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/kb_manager.py +0 -0
  35. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/models.py +0 -0
  36. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/search_engine.py +0 -0
  37. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/template.py +0 -0
  38. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/template_cli.py +0 -0
  39. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/jfox/vector_store.py +0 -0
  40. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/pytest.ini +0 -0
  41. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/run_full_test.ps1 +0 -0
  42. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skill/evals/evals.json +0 -0
  43. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skill/knowledge-base-notes/SKILL.md +0 -0
  44. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skill/knowledge-base-workspace/SKILL.md +0 -0
  45. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skills-recommend/README.md +0 -0
  46. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-health/SKILL.md +0 -0
  47. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-init/SKILL.md +0 -0
  48. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-insert/SKILL.md +0 -0
  49. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-organize/SKILL.md +0 -0
  50. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-search/SKILL.md +0 -0
  51. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/COVERAGE_PLAN.md +0 -0
  52. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/MIGRATION.md +0 -0
  53. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/TESTS.md +0 -0
  54. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/conftest.py +0 -0
  55. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/integration/__init__.py +0 -0
  56. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/integration/test_backlinks.py +0 -0
  57. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/performance/__init__.py +0 -0
  58. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/performance/test_performance.py +0 -0
  59. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_advanced_features.py +0 -0
  60. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_config_unit.py +0 -0
  61. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_core_workflow.py +0 -0
  62. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_hybrid_search.py +0 -0
  63. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_integration.py +0 -0
  64. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_kb_current.py +0 -0
  65. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/test_suggest_links.py +0 -0
  66. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/unit/__init__.py +0 -0
  67. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/unit/test_formatters.py +0 -0
  68. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/unit/test_global_config.py +0 -0
  69. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/unit/test_kb_manager.py +0 -0
  70. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/unit/test_template.py +0 -0
  71. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/unit/test_template_cli.py +0 -0
  72. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/utils/__init__.py +0 -0
  73. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/utils/assertions.py +0 -0
  74. {jfox_cli-0.1.3 → jfox_cli-0.1.4}/tests/utils/note_generator.py +0 -0
  75. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jfox-cli
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: JFox - Zettelkasten 知识管理 CLI 工具
5
5
  Project-URL: Homepage, https://github.com/zhuxixi/jfox
6
6
  Project-URL: Repository, https://github.com/zhuxixi/jfox
@@ -1,5 +1,5 @@
1
1
  """JFox - Zettelkasten 知识管理工具"""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.4"
4
4
  __author__ = "User"
5
5
  __email__ = "user@example.com"
@@ -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
- json_output: bool = typer.Option(True, "--json/--no-json", help="JSON 输出"),
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 json_output:
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 json_output:
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 json_output:
165
+
166
+ if output_format == "json":
162
167
  print(output_json(result))
163
168
  else:
164
- console.print(f"[green]✓[/green] {message}")
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 json_output:
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 json_output:
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
- json_output: bool,
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 json_output:
343
+
344
+ if output_format == "json":
326
345
  print(output_json(result))
327
346
  else:
328
- console.print(f"[green]✓[/green] Note created: {new_note.title}")
329
- console.print(f" ID: {new_note.id}")
330
- console.print(f" Path: {new_note.filepath}")
331
- if resolved_links:
332
- console.print(f" Links: {len(resolved_links)} connection(s)")
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
- json_output: bool = typer.Option(True, "--json/--no-json", help="JSON 输出"),
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, json_output, template)
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, json_output, template)
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 json_output:
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
- json_output: bool,
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
- console.print(f"[red]Note not found: {note_id}[/red]")
834
- raise typer.Exit(1)
835
-
860
+ raise ValueError(f"Note not found: {note_id}")
861
+
836
862
  # 确认删除
837
863
  if not force:
838
- if json_output:
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 json_output:
881
+
882
+ if output_format == "json":
857
883
  print(output_json(result))
858
884
  else:
859
- console.print(f"[green]✓[/green] Deleted: {n.title}")
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
- json_output: bool = typer.Option(True, "--json/--no-json", help="JSON 输出"),
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, json_output)
911
+ _delete_impl(note_id, force, output_format)
878
912
  else:
879
- _delete_impl(note_id, force, json_output)
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "jfox-cli"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "JFox - Zettelkasten 知识管理 CLI 工具"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}