jfox-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jfox/indexer.py ADDED
@@ -0,0 +1,366 @@
1
+ """
2
+ File system indexer with watchdog for auto-indexing Zettelkasten notes.
3
+
4
+ Provides:
5
+ - Real-time file monitoring
6
+ - Automatic indexing on file changes
7
+ - Batch indexing for initial setup
8
+ - Index status and statistics
9
+ """
10
+
11
+ import os
12
+ import time
13
+ import threading
14
+ from pathlib import Path
15
+ from typing import Callable, Dict, List, Optional, Set
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime
18
+ from collections import deque
19
+
20
+ from watchdog.observers import Observer
21
+ from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent, FileDeletedEvent
22
+ from rich.console import Console
23
+ from rich.progress import Progress, SpinnerColumn, TextColumn
24
+
25
+ from .config import ZKConfig
26
+ from .models import Note
27
+ from .vector_store import VectorStore
28
+ from .graph import KnowledgeGraph
29
+
30
+
31
+ console = Console()
32
+
33
+
34
+ @dataclass
35
+ class IndexStats:
36
+ """Statistics for the indexer."""
37
+ total_indexed: int = 0
38
+ last_indexed: Optional[datetime] = None
39
+ pending_changes: int = 0
40
+ errors: List[str] = field(default_factory=list)
41
+
42
+
43
+ class NoteEventHandler(FileSystemEventHandler):
44
+ """
45
+ Handles file system events for note files.
46
+
47
+ Debounces rapid file changes and batches indexing operations.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ vector_store: VectorStore,
53
+ config: ZKConfig,
54
+ debounce_seconds: float = 1.0,
55
+ callback: Optional[Callable[[str, str], None]] = None
56
+ ):
57
+ self.vector_store = vector_store
58
+ self.config = config
59
+ self.debounce_seconds = debounce_seconds
60
+ self.callback = callback
61
+
62
+ self._pending: Dict[str, float] = {}
63
+ self._lock = threading.Lock()
64
+ self._timer: Optional[threading.Timer] = None
65
+ self.stats = IndexStats()
66
+
67
+ def on_modified(self, event):
68
+ if event.is_directory:
69
+ return
70
+ if not event.src_path.endswith(".md"):
71
+ return
72
+
73
+ with self._lock:
74
+ self._pending[event.src_path] = time.time()
75
+ self._schedule_process()
76
+
77
+ on_created = on_modified # Treat creation same as modification
78
+
79
+ def on_deleted(self, event):
80
+ if event.is_directory:
81
+ return
82
+ if not event.src_path.endswith(".md"):
83
+ return
84
+
85
+ # Remove from vector store
86
+ note_id = Path(event.src_path).stem
87
+ try:
88
+ self.vector_store.delete_note(note_id)
89
+ if self.callback:
90
+ self.callback("deleted", note_id)
91
+ except Exception as e:
92
+ self.stats.errors.append(f"Failed to delete {note_id}: {e}")
93
+
94
+ def _schedule_process(self):
95
+ """Schedule processing of pending changes."""
96
+ if self._timer:
97
+ self._timer.cancel()
98
+
99
+ self._timer = threading.Timer(self.debounce_seconds, self._process_pending)
100
+ self._timer.daemon = True
101
+ self._timer.start()
102
+
103
+ def _process_pending(self):
104
+ """Process all pending file changes."""
105
+ with self._lock:
106
+ now = time.time()
107
+ ready = [
108
+ path for path, timestamp in self._pending.items()
109
+ if now - timestamp >= self.debounce_seconds
110
+ ]
111
+
112
+ for path in ready:
113
+ del self._pending[path]
114
+
115
+ self.stats.pending_changes = len(self._pending)
116
+
117
+ for path in ready:
118
+ self._index_file(path)
119
+
120
+ def _index_file(self, file_path: str):
121
+ """Index a single note file."""
122
+ from .note import NoteManager
123
+
124
+ try:
125
+ note = NoteManager.load_note(Path(file_path))
126
+ if not note:
127
+ return
128
+
129
+ # Index in vector store
130
+ self.vector_store.add_or_update_note(note)
131
+
132
+ self.stats.total_indexed += 1
133
+ self.stats.last_indexed = datetime.now()
134
+
135
+ if self.callback:
136
+ self.callback("indexed", note.id)
137
+
138
+ except Exception as e:
139
+ error_msg = f"Failed to index {file_path}: {e}"
140
+ self.stats.errors.append(error_msg)
141
+ if self.callback:
142
+ self.callback("error", error_msg)
143
+
144
+
145
+ class Indexer:
146
+ """
147
+ Manages file system monitoring and indexing for Zettelkasten notes.
148
+
149
+ Usage:
150
+ indexer = Indexer(config, vector_store)
151
+ indexer.start() # Start watching
152
+ ...
153
+ indexer.stop() # Stop watching
154
+ """
155
+
156
+ def __init__(
157
+ self,
158
+ config: ZKConfig,
159
+ vector_store: VectorStore,
160
+ debounce_seconds: float = 1.0
161
+ ):
162
+ self.config = config
163
+ self.vector_store = vector_store
164
+ self.debounce_seconds = debounce_seconds
165
+
166
+ self._observer: Optional[Observer] = None
167
+ self._handler: Optional[NoteEventHandler] = None
168
+ self._running = False
169
+
170
+ def start(self, callback: Optional[Callable[[str, str], None]] = None):
171
+ """
172
+ Start the file system observer.
173
+
174
+ Args:
175
+ callback: Optional callback for events (event_type, message)
176
+ """
177
+ if self._running:
178
+ return
179
+
180
+ notes_dir = Path(self.config.notes_dir)
181
+ if not notes_dir.exists():
182
+ console.print(f"[yellow]Notes directory not found: {notes_dir}[/yellow]")
183
+ return
184
+
185
+ self._handler = NoteEventHandler(
186
+ self.vector_store,
187
+ self.config,
188
+ self.debounce_seconds,
189
+ callback
190
+ )
191
+
192
+ self._observer = Observer()
193
+ self._observer.schedule(self._handler, str(notes_dir), recursive=True)
194
+ self._observer.start()
195
+
196
+ self._running = True
197
+ console.print(f"[green]Started indexer watching: {notes_dir}[/green]")
198
+
199
+ def stop(self):
200
+ """Stop the file system observer."""
201
+ if not self._running:
202
+ return
203
+
204
+ if self._observer:
205
+ self._observer.stop()
206
+ self._observer.join()
207
+
208
+ self._running = False
209
+ console.print("[green]Indexer stopped[/green]")
210
+
211
+ def is_running(self) -> bool:
212
+ """Check if the indexer is running."""
213
+ return self._running
214
+
215
+ def get_stats(self) -> IndexStats:
216
+ """Get indexer statistics."""
217
+ if self._handler:
218
+ return self._handler.stats
219
+ return IndexStats()
220
+
221
+ def index_all(self, progress_callback: Optional[Callable[[int, int], None]] = None) -> int:
222
+ """
223
+ Perform full re-indexing of all notes.
224
+
225
+ Args:
226
+ progress_callback: Called with (current, total) for progress updates
227
+
228
+ Returns:
229
+ Number of notes indexed
230
+ """
231
+ from .note import NoteManager
232
+
233
+ notes_dir = Path(self.config.notes_dir)
234
+ if not notes_dir.exists():
235
+ return 0
236
+
237
+ # Find all note files
238
+ note_files = list(notes_dir.rglob("*.md"))
239
+ total = len(note_files)
240
+
241
+ if total == 0:
242
+ return 0
243
+
244
+ indexed = 0
245
+
246
+ with Progress(
247
+ SpinnerColumn(),
248
+ TextColumn("[progress.description]{task.description}"),
249
+ console=console
250
+ ) as progress:
251
+ task = progress.add_task(f"Indexing {total} notes...", total=total)
252
+
253
+ for i, note_file in enumerate(note_files):
254
+ try:
255
+ note = NoteManager.load_note(note_file)
256
+ if note:
257
+ self.vector_store.add_or_update_note(note)
258
+ indexed += 1
259
+
260
+ if self._handler:
261
+ self._handler.stats.total_indexed += 1
262
+
263
+ progress.update(task, advance=1)
264
+
265
+ if progress_callback:
266
+ progress_callback(i + 1, total)
267
+
268
+ except Exception as e:
269
+ console.print(f"[red]Failed to index {note_file}: {e}[/red]")
270
+
271
+ return indexed
272
+
273
+ def index_note(self, note_id: str) -> bool:
274
+ """
275
+ Index a specific note by ID.
276
+
277
+ Args:
278
+ note_id: The note ID to index
279
+
280
+ Returns:
281
+ True if successful
282
+ """
283
+ from .note import NoteManager
284
+
285
+ note_file = NoteManager.find_note_file(self.config, note_id)
286
+ if not note_file:
287
+ return False
288
+
289
+ try:
290
+ note = NoteManager.load_note(note_file)
291
+ if note:
292
+ self.vector_store.add_or_update_note(note)
293
+ return True
294
+ except Exception as e:
295
+ console.print(f"[red]Failed to index {note_id}: {e}[/red]")
296
+
297
+ return False
298
+
299
+ def verify_index(self) -> Dict[str, any]:
300
+ """
301
+ Verify the index integrity.
302
+
303
+ Returns:
304
+ Dict with verification results
305
+ """
306
+ from .note import NoteManager
307
+
308
+ notes_dir = Path(self.config.notes_dir)
309
+ if not notes_dir.exists():
310
+ return {"error": "Notes directory not found"}
311
+
312
+ # Get all note files
313
+ note_files = list(notes_dir.rglob("*.md"))
314
+ file_ids = {f.stem for f in note_files}
315
+
316
+ # Get all indexed notes
317
+ indexed_ids = set(self.vector_store.get_all_ids())
318
+
319
+ missing_from_index = file_ids - indexed_ids
320
+ orphaned_in_index = indexed_ids - file_ids
321
+
322
+ return {
323
+ "total_files": len(file_ids),
324
+ "total_indexed": len(indexed_ids),
325
+ "missing_from_index": list(missing_from_index),
326
+ "orphaned_in_index": list(orphaned_in_index),
327
+ "healthy": len(missing_from_index) == 0 and len(orphaned_in_index) == 0
328
+ }
329
+
330
+
331
+ class IndexerDaemon:
332
+ """
333
+ Daemon wrapper for running indexer in background.
334
+
335
+ Usage:
336
+ daemon = IndexerDaemon(config, vector_store)
337
+ daemon.run() # Blocks until stopped
338
+ """
339
+
340
+ def __init__(self, config: ZKConfig, vector_store: VectorStore):
341
+ self.indexer = Indexer(config, vector_store)
342
+ self._stop_event = threading.Event()
343
+
344
+ def run(self):
345
+ """Run the daemon until stopped."""
346
+ self.indexer.start(callback=self._on_event)
347
+
348
+ try:
349
+ while not self._stop_event.is_set():
350
+ self._stop_event.wait(1)
351
+ except KeyboardInterrupt:
352
+ pass
353
+ finally:
354
+ self.indexer.stop()
355
+
356
+ def stop(self):
357
+ """Signal the daemon to stop."""
358
+ self._stop_event.set()
359
+
360
+ def _on_event(self, event_type: str, message: str):
361
+ """Handle indexer events."""
362
+ timestamp = datetime.now().strftime("%H:%M:%S")
363
+ if event_type == "error":
364
+ console.print(f"[{timestamp}] [red]Error: {message}[/red]")
365
+ else:
366
+ console.print(f"[{timestamp}] [green]{event_type}: {message}[/green]")
jfox/kb_manager.py ADDED
@@ -0,0 +1,316 @@
1
+ """
2
+ 知识库管理器 - 高级知识库操作
3
+
4
+ 提供知识库的创建、删除、统计等功能
5
+ """
6
+
7
+ import shutil
8
+ import logging
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import List, Optional, Dict, Any
13
+
14
+ from .global_config import (
15
+ GlobalConfigManager,
16
+ KnowledgeBaseEntry,
17
+ get_global_config_manager,
18
+ DEFAULT_KB_PATH
19
+ )
20
+ from .config import ZKConfig
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class KBStats:
28
+ """知识库统计信息"""
29
+ name: str
30
+ path: Path
31
+ total_notes: int
32
+ by_type: Dict[str, int]
33
+ created: Optional[str] = None
34
+ last_used: Optional[str] = None
35
+ description: Optional[str] = None
36
+ is_current: bool = False
37
+
38
+
39
+ class KnowledgeBaseManager:
40
+ """
41
+ 知识库管理器
42
+
43
+ 提供知识库的完整生命周期管理:
44
+ - 创建、删除、重命名
45
+ - 统计信息
46
+ - 切换默认知识库
47
+ """
48
+
49
+ def __init__(self, config_manager: Optional[GlobalConfigManager] = None):
50
+ self.config_manager = config_manager or get_global_config_manager()
51
+
52
+ def create(
53
+ self,
54
+ name: str,
55
+ path: Optional[Path] = None,
56
+ description: Optional[str] = None,
57
+ set_as_default: bool = False
58
+ ) -> tuple[bool, str]:
59
+ """
60
+ 创建新知识库
61
+
62
+ Args:
63
+ name: 知识库名称
64
+ path: 存储路径(默认 ~/.zettelkasten/<name>/)
65
+ description: 描述
66
+ set_as_default: 是否设为默认
67
+
68
+ Returns:
69
+ (success, message)
70
+ """
71
+ # 检查名称是否已存在
72
+ if self.config_manager.kb_exists(name):
73
+ return False, f"Knowledge base '{name}' already exists"
74
+
75
+ # 检查名称不与保留目录冲突(default KB 的内部目录)
76
+ reserved_names = {"notes", ".zk", ".zk_config.json"}
77
+ if name.lower() in reserved_names:
78
+ return False, f"Name '{name}' is reserved and cannot be used as a KB name"
79
+
80
+ # 确定路径(统一管理到 ~/.zettelkasten/ 下)
81
+ if path is None:
82
+ if name == "default":
83
+ path = DEFAULT_KB_PATH
84
+ else:
85
+ path = DEFAULT_KB_PATH / name
86
+
87
+ path = path.expanduser().resolve()
88
+
89
+ # 校验路径必须在统一管理目录下
90
+ kb_root = DEFAULT_KB_PATH.resolve()
91
+ try:
92
+ path.relative_to(kb_root)
93
+ except ValueError:
94
+ return (
95
+ False,
96
+ f"Path '{path}' is outside managed directory '{kb_root}'. "
97
+ f"All knowledge bases must be under {kb_root}/",
98
+ )
99
+
100
+ # 检查路径是否已被其他知识库使用
101
+ for kb in self.config_manager.list_knowledge_bases():
102
+ if Path(kb.path) == path:
103
+ return False, f"Path '{path}' is already used by another knowledge base"
104
+
105
+ # 创建目录结构
106
+ try:
107
+ config = ZKConfig(base_dir=path)
108
+ config.ensure_dirs()
109
+
110
+ # 添加到配置
111
+ if not self.config_manager.add_knowledge_base(name, path, description):
112
+ return False, "Failed to register knowledge base"
113
+
114
+ # 设为默认(如果需要)
115
+ if set_as_default:
116
+ self.config_manager.set_default(name)
117
+
118
+ return True, f"Created knowledge base '{name}' at {path}"
119
+
120
+ except Exception as e:
121
+ logger.error(f"Failed to create knowledge base: {e}")
122
+ return False, str(e)
123
+
124
+ def remove(
125
+ self,
126
+ name: str,
127
+ delete_data: bool = False
128
+ ) -> tuple[bool, str]:
129
+ """
130
+ 移除知识库
131
+
132
+ Args:
133
+ name: 知识库名称
134
+ delete_data: 是否删除实际数据
135
+
136
+ Returns:
137
+ (success, message)
138
+ """
139
+ if not self.config_manager.kb_exists(name):
140
+ return False, f"Knowledge base '{name}' not found"
141
+
142
+ # 获取路径
143
+ path = self.config_manager.get_kb_path(name)
144
+
145
+ # 从配置中移除
146
+ if not self.config_manager.remove_knowledge_base(name):
147
+ return False, "Failed to unregister knowledge base"
148
+
149
+ # 删除数据(如果需要)
150
+ if delete_data and path and path.exists():
151
+ # 禁止删除 default KB 的数据目录,因为其他命名 KB 也在其中
152
+ if path.resolve() == DEFAULT_KB_PATH.resolve():
153
+ return (
154
+ True,
155
+ f"Removed knowledge base '{name}' from config, "
156
+ f"but cannot delete data: default KB directory contains other KBs",
157
+ )
158
+ try:
159
+ shutil.rmtree(path)
160
+ return True, f"Removed knowledge base '{name}' and deleted all data"
161
+ except Exception as e:
162
+ return True, f"Removed knowledge base '{name}' but failed to delete data: {e}"
163
+
164
+ return True, f"Removed knowledge base '{name}' (data preserved at {path})"
165
+
166
+ def rename(self, old_name: str, new_name: str) -> tuple[bool, str]:
167
+ """
168
+ 重命名知识库
169
+
170
+ Args:
171
+ old_name: 原名称
172
+ new_name: 新名称
173
+
174
+ Returns:
175
+ (success, message)
176
+ """
177
+ if not self.config_manager.kb_exists(old_name):
178
+ return False, f"Knowledge base '{old_name}' not found"
179
+
180
+ if self.config_manager.kb_exists(new_name):
181
+ return False, f"Knowledge base '{new_name}' already exists"
182
+
183
+ if self.config_manager.rename_knowledge_base(old_name, new_name):
184
+ return True, f"Renamed '{old_name}' to '{new_name}'"
185
+
186
+ return False, "Failed to rename knowledge base"
187
+
188
+ def switch(self, name: str) -> tuple[bool, str]:
189
+ """
190
+ 切换默认知识库
191
+
192
+ Args:
193
+ name: 知识库名称
194
+
195
+ Returns:
196
+ (success, message)
197
+ """
198
+ if not self.config_manager.kb_exists(name):
199
+ return False, f"Knowledge base '{name}' not found"
200
+
201
+ # 更新最后使用时间
202
+ self.config_manager.update_last_used(name)
203
+
204
+ if self.config_manager.set_default(name):
205
+ path = self.config_manager.get_kb_path(name)
206
+ return True, f"Switched to '{name}' ({path})"
207
+
208
+ return False, "Failed to switch knowledge base"
209
+
210
+ def list_all(self) -> List[KBStats]:
211
+ """
212
+ 列出所有知识库及其统计信息
213
+
214
+ Returns:
215
+ 知识库统计信息列表
216
+ """
217
+ entries = self.config_manager.list_knowledge_bases()
218
+ current = self.config_manager.get_default_kb_name()
219
+
220
+ stats_list = []
221
+ for entry in entries:
222
+ stats = self._get_kb_stats(entry)
223
+ stats.is_current = (entry.name == current)
224
+ stats_list.append(stats)
225
+
226
+ return stats_list
227
+
228
+ def get_info(self, name: str) -> Optional[KBStats]:
229
+ """
230
+ 获取知识库详细信息
231
+
232
+ Args:
233
+ name: 知识库名称
234
+
235
+ Returns:
236
+ 知识库统计信息,不存在则返回 None
237
+ """
238
+ if not self.config_manager.kb_exists(name):
239
+ return None
240
+
241
+ entries = self.config_manager.list_knowledge_bases()
242
+ for entry in entries:
243
+ if entry.name == name:
244
+ stats = self._get_kb_stats(entry)
245
+ stats.is_current = (name == self.config_manager.get_default_kb_name())
246
+ return stats
247
+
248
+ return None
249
+
250
+ def _get_kb_stats(self, entry: KnowledgeBaseEntry) -> KBStats:
251
+ """获取知识库统计信息"""
252
+ path = Path(entry.path)
253
+
254
+ # 统计笔记数量
255
+ total = 0
256
+ by_type = {"fleeting": 0, "literature": 0, "permanent": 0}
257
+
258
+ if path.exists():
259
+ notes_dir = path / "notes"
260
+ if notes_dir.exists():
261
+ for note_type in ["fleeting", "literature", "permanent"]:
262
+ type_dir = notes_dir / note_type
263
+ if type_dir.exists():
264
+ count = len(list(type_dir.glob("*.md")))
265
+ by_type[note_type] = count
266
+ total += count
267
+
268
+ return KBStats(
269
+ name=entry.name,
270
+ path=path,
271
+ total_notes=total,
272
+ by_type=by_type,
273
+ created=entry.created,
274
+ last_used=entry.last_used,
275
+ description=entry.description,
276
+ is_current=False,
277
+ )
278
+
279
+ def get_current_kb_info(self) -> Optional[KBStats]:
280
+ """获取当前默认知识库信息"""
281
+ current_name = self.config_manager.get_default_kb_name()
282
+ return self.get_info(current_name)
283
+
284
+ def ensure_default_exists(self) -> bool:
285
+ """
286
+ 确保默认知识库存在,如果不存在则创建
287
+
288
+ Returns:
289
+ 是否成功
290
+ """
291
+ default_name = self.config_manager.get_default_kb_name()
292
+
293
+ if not self.config_manager.kb_exists(default_name):
294
+ # 创建默认知识库
295
+ path = self.config_manager.get_default_kb_path()
296
+ success, _ = self.create(
297
+ name=default_name,
298
+ path=path,
299
+ description="Default knowledge base",
300
+ set_as_default=True
301
+ )
302
+ return success
303
+
304
+ return True
305
+
306
+
307
+ # 全局知识库管理器实例
308
+ _kb_manager: Optional[KnowledgeBaseManager] = None
309
+
310
+
311
+ def get_kb_manager() -> KnowledgeBaseManager:
312
+ """获取知识库管理器实例"""
313
+ global _kb_manager
314
+ if _kb_manager is None:
315
+ _kb_manager = KnowledgeBaseManager()
316
+ return _kb_manager