jfox-cli 0.1.2__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.2 → jfox_cli-0.1.4}/CLAUDE.md +1 -0
  2. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/PKG-INFO +1 -1
  3. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/__init__.py +1 -1
  4. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/bm25_index.py +72 -0
  5. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/cli.py +237 -37
  6. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/global_config.py +42 -11
  7. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/note.py +78 -4
  8. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/performance.py +7 -0
  9. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/pyproject.toml +1 -1
  10. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_cli_format.py +124 -0
  11. jfox_cli-0.1.4/tests/unit/test_bm25_batch.py +263 -0
  12. jfox_cli-0.1.4/tests/unit/test_edit.py +389 -0
  13. jfox_cli-0.1.4/tests/unit/test_format_unify.py +301 -0
  14. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/unit/test_global_config.py +144 -0
  15. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/utils/jfox_cli.py +44 -4
  16. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/uv.lock +1 -1
  17. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/.githooks/pre-push +0 -0
  18. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/.github/workflows/integration-test.yml +0 -0
  19. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/.github/workflows/publish.yml +0 -0
  20. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/.gitignore +0 -0
  21. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/.python-version +0 -0
  22. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/AGENTS.md +0 -0
  23. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/DEVELOPMENT_PLAN.md +0 -0
  24. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/README.md +0 -0
  25. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/SESSION.md +0 -0
  26. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/SESSION_SUMMARY.md +0 -0
  27. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/docs/superpowers/specs/2026-04-03-bugfixes-design.md +0 -0
  28. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jessica-jones-static-cable.md +0 -0
  29. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/__main__.py +0 -0
  30. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/config.py +0 -0
  31. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/embedding_backend.py +0 -0
  32. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/formatters.py +0 -0
  33. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/graph.py +0 -0
  34. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/indexer.py +0 -0
  35. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/kb_manager.py +0 -0
  36. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/models.py +0 -0
  37. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/search_engine.py +0 -0
  38. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/template.py +0 -0
  39. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/template_cli.py +0 -0
  40. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/vector_store.py +0 -0
  41. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/pytest.ini +0 -0
  42. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/run_full_test.ps1 +0 -0
  43. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skill/evals/evals.json +0 -0
  44. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skill/knowledge-base-notes/SKILL.md +0 -0
  45. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skill/knowledge-base-workspace/SKILL.md +0 -0
  46. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skills-recommend/README.md +0 -0
  47. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-health/SKILL.md +0 -0
  48. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-init/SKILL.md +0 -0
  49. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-insert/SKILL.md +0 -0
  50. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-organize/SKILL.md +0 -0
  51. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-search/SKILL.md +0 -0
  52. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/COVERAGE_PLAN.md +0 -0
  53. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/MIGRATION.md +0 -0
  54. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/TESTS.md +0 -0
  55. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/conftest.py +0 -0
  56. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/integration/__init__.py +0 -0
  57. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/integration/test_backlinks.py +0 -0
  58. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/performance/__init__.py +0 -0
  59. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/performance/test_performance.py +0 -0
  60. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_advanced_features.py +0 -0
  61. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_config_unit.py +0 -0
  62. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_core_workflow.py +0 -0
  63. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_hybrid_search.py +0 -0
  64. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_integration.py +0 -0
  65. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_kb_current.py +0 -0
  66. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_suggest_links.py +0 -0
  67. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/unit/__init__.py +0 -0
  68. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/unit/test_formatters.py +0 -0
  69. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/unit/test_kb_manager.py +0 -0
  70. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/unit/test_template.py +0 -0
  71. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/unit/test_template_cli.py +0 -0
  72. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/utils/__init__.py +0 -0
  73. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/utils/assertions.py +0 -0
  74. {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/utils/note_generator.py +0 -0
  75. {jfox_cli-0.1.2 → 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.2
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.1"
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}")
@@ -115,22 +115,53 @@ class GlobalConfigManager:
115
115
  try:
116
116
  current_resolved = Path(default_kb.path).expanduser().resolve()
117
117
  if current_resolved == old_path.resolve():
118
- # 迁移:将旧目录内容移到新路径
119
118
  if old_path.exists() and not new_path.exists():
120
119
  import shutil
121
120
  new_path.mkdir(parents=True, exist_ok=True)
122
- # 移动 notes/ 和 .zk/ 目录
123
- for subdir in ["notes", ".zk"]:
124
- src = old_path / subdir
125
- if src.exists():
126
- shutil.move(str(src), str(new_path / subdir))
121
+ try:
122
+ # 移动 notes/ .zk/ 目录
123
+ for subdir in ["notes", ".zk"]:
124
+ src = old_path / subdir
125
+ if src.exists():
126
+ shutil.move(str(src), str(new_path / subdir))
127
+ except Exception as move_error:
128
+ # 回滚:移回已迁移的子目录
129
+ rollback_errors = []
130
+ for subdir in ["notes", ".zk"]:
131
+ moved = new_path / subdir
132
+ if moved.exists():
133
+ try:
134
+ shutil.move(str(moved), str(old_path / subdir))
135
+ except Exception as rb_err:
136
+ rollback_errors.append(
137
+ f"Failed to roll back {subdir}: {rb_err}"
138
+ )
139
+ # 清理空的 new_path 目录
140
+ try:
141
+ new_path.rmdir()
142
+ except OSError:
143
+ logger.debug(
144
+ f"Could not remove {new_path} during rollback"
145
+ )
146
+ if rollback_errors:
147
+ logger.error(
148
+ f"Migration failed ({move_error}) AND rollback had "
149
+ f"errors: {'; '.join(rollback_errors)}"
150
+ )
151
+ raise
127
152
  logger.info(f"Migrated default KB from {old_path} to {new_path}")
128
- # 更新配置中的路径
129
- default_kb.path = str(new_path)
130
- self._save()
131
- logger.info(f"Updated default KB path to {new_path}")
153
+ # 仅在文件迁移成功后才更新 config
154
+ default_kb.path = str(new_path)
155
+ self._save()
156
+ else:
157
+ # 旧路径已不存在或新路径已存在,仅更新 config 指向
158
+ default_kb.path = str(new_path)
159
+ self._save()
132
160
  except Exception as e:
133
- logger.warning(f"Failed to migrate default KB path: {e}")
161
+ logger.error(
162
+ f"Failed to migrate default KB path from {old_path} to "
163
+ f"{new_path}: {e}", exc_info=True
164
+ )
134
165
 
135
166
  def _save(self) -> bool:
136
167
  """保存配置到文件"""