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.
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/CLAUDE.md +1 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/PKG-INFO +1 -1
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/__init__.py +1 -1
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/bm25_index.py +72 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/cli.py +237 -37
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/global_config.py +42 -11
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/note.py +78 -4
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/performance.py +7 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/pyproject.toml +1 -1
- {jfox_cli-0.1.2 → 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.2 → jfox_cli-0.1.4}/tests/unit/test_global_config.py +144 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/utils/jfox_cli.py +44 -4
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/uv.lock +1 -1
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/.githooks/pre-push +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/.github/workflows/integration-test.yml +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/.github/workflows/publish.yml +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/.gitignore +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/.python-version +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/AGENTS.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/DEVELOPMENT_PLAN.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/README.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/SESSION.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/SESSION_SUMMARY.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/docs/superpowers/specs/2026-04-03-bugfixes-design.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jessica-jones-static-cable.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/__main__.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/config.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/embedding_backend.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/formatters.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/graph.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/indexer.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/kb_manager.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/models.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/search_engine.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/template.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/template_cli.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/jfox/vector_store.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/pytest.ini +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/run_full_test.ps1 +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skill/evals/evals.json +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skill/knowledge-base-notes/SKILL.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skill/knowledge-base-workspace/SKILL.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skills-recommend/README.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-health/SKILL.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-init/SKILL.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-insert/SKILL.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-organize/SKILL.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/skills-recommend/claude-code/jfox-search/SKILL.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/COVERAGE_PLAN.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/MIGRATION.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/TESTS.md +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/conftest.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/integration/__init__.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/integration/test_backlinks.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/performance/__init__.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/performance/test_performance.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_advanced_features.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_config_unit.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_core_workflow.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_hybrid_search.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_integration.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_kb_current.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/test_suggest_links.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/unit/__init__.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/unit/test_formatters.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/unit/test_kb_manager.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/unit/test_template.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/unit/test_template_cli.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/utils/__init__.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/utils/assertions.py +0 -0
- {jfox_cli-0.1.2 → jfox_cli-0.1.4}/tests/utils/note_generator.py +0 -0
- {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
|
|
@@ -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}")
|
|
@@ -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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
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
|
"""保存配置到文件"""
|