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/global_config.py ADDED
@@ -0,0 +1,281 @@
1
+ """
2
+ 全局配置管理 - 多知识库支持
3
+
4
+ 管理 ~/.zk_config.json,存储所有知识库的注册信息和默认设置
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ from dataclasses import dataclass, field, asdict
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Any
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ DEFAULT_CONFIG_PATH = Path.home() / ".zk_config.json"
20
+ DEFAULT_KB_NAME = "default"
21
+ DEFAULT_KB_PATH = Path(os.environ.get('ZK_KB_ROOT', str(Path.home() / '.zettelkasten')))
22
+
23
+
24
+ @dataclass
25
+ class KnowledgeBaseEntry:
26
+ """知识库条目"""
27
+ name: str
28
+ path: str
29
+ created: str
30
+ description: Optional[str] = None
31
+ last_used: Optional[str] = None
32
+
33
+ def to_dict(self) -> Dict[str, Any]:
34
+ return asdict(self)
35
+
36
+ @classmethod
37
+ def from_dict(cls, name: str, data: Dict[str, Any]) -> "KnowledgeBaseEntry":
38
+ return cls(
39
+ name=name,
40
+ path=data.get("path", ""),
41
+ created=data.get("created", datetime.now().isoformat()),
42
+ description=data.get("description"),
43
+ last_used=data.get("last_used"),
44
+ )
45
+
46
+
47
+ @dataclass
48
+ class GlobalConfig:
49
+ """全局配置"""
50
+ default: str = DEFAULT_KB_NAME
51
+ knowledge_bases: Dict[str, KnowledgeBaseEntry] = field(default_factory=dict)
52
+
53
+ def to_dict(self) -> Dict[str, Any]:
54
+ return {
55
+ "default": self.default,
56
+ "knowledge_bases": {
57
+ name: kb.to_dict() for name, kb in self.knowledge_bases.items()
58
+ }
59
+ }
60
+
61
+ @classmethod
62
+ def from_dict(cls, data: Dict[str, Any]) -> "GlobalConfig":
63
+ kbs = {}
64
+ for name, kb_data in data.get("knowledge_bases", {}).items():
65
+ kbs[name] = KnowledgeBaseEntry.from_dict(name, kb_data)
66
+
67
+ return cls(
68
+ default=data.get("default", DEFAULT_KB_NAME),
69
+ knowledge_bases=kbs,
70
+ )
71
+
72
+
73
+ class GlobalConfigManager:
74
+ """
75
+ 全局配置管理器
76
+
77
+ 负责管理 ~/.zk_config.json 的读写操作
78
+ """
79
+
80
+ def __init__(self, config_path: Optional[Path] = None):
81
+ self.config_path = config_path or DEFAULT_CONFIG_PATH
82
+ self._config: Optional[GlobalConfig] = None
83
+
84
+ def _load(self) -> GlobalConfig:
85
+ """加载配置,如果不存在则创建默认配置"""
86
+ if self._config is not None:
87
+ return self._config
88
+
89
+ if self.config_path.exists():
90
+ try:
91
+ with open(self.config_path, 'r', encoding='utf-8') as f:
92
+ data = json.load(f)
93
+ self._config = GlobalConfig.from_dict(data)
94
+ logger.debug(f"Loaded global config from {self.config_path}")
95
+ except Exception as e:
96
+ logger.warning(f"Failed to load config: {e}, creating default")
97
+ self._config = self._create_default_config()
98
+ else:
99
+ self._config = self._create_default_config()
100
+
101
+ return self._config
102
+
103
+ def _save(self) -> bool:
104
+ """保存配置到文件"""
105
+ try:
106
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
107
+ with open(self.config_path, 'w', encoding='utf-8') as f:
108
+ json.dump(self._config.to_dict(), f, ensure_ascii=False, indent=2)
109
+ logger.debug(f"Saved global config to {self.config_path}")
110
+ return True
111
+ except Exception as e:
112
+ logger.error(f"Failed to save config: {e}")
113
+ return False
114
+
115
+ def _create_default_config(self) -> GlobalConfig:
116
+ """创建默认配置"""
117
+ default_kb = KnowledgeBaseEntry(
118
+ name=DEFAULT_KB_NAME,
119
+ path=str(DEFAULT_KB_PATH),
120
+ created=datetime.now().isoformat(),
121
+ description="Default knowledge base",
122
+ )
123
+
124
+ config = GlobalConfig(
125
+ default=DEFAULT_KB_NAME,
126
+ knowledge_bases={DEFAULT_KB_NAME: default_kb}
127
+ )
128
+
129
+ # 如果默认知识库已存在,保留它
130
+ if DEFAULT_KB_PATH.exists():
131
+ self._config = config
132
+ self._save()
133
+
134
+ return config
135
+
136
+ def get_config(self) -> GlobalConfig:
137
+ """获取当前配置"""
138
+ return self._load()
139
+
140
+ def get_default_kb_name(self) -> str:
141
+ """获取默认知识库名称"""
142
+ return self._load().default
143
+
144
+ def get_default_kb_path(self) -> Path:
145
+ """获取默认知识库路径"""
146
+ config = self._load()
147
+ default_name = config.default
148
+
149
+ if default_name in config.knowledge_bases:
150
+ return Path(config.knowledge_bases[default_name].path)
151
+
152
+ # 回退到默认路径
153
+ return DEFAULT_KB_PATH
154
+
155
+ def get_kb_path(self, name: str) -> Optional[Path]:
156
+ """获取指定知识库的路径"""
157
+ config = self._load()
158
+ if name in config.knowledge_bases:
159
+ return Path(config.knowledge_bases[name].path)
160
+ return None
161
+
162
+ def list_knowledge_bases(self) -> List[KnowledgeBaseEntry]:
163
+ """列出所有知识库"""
164
+ config = self._load()
165
+ return list(config.knowledge_bases.values())
166
+
167
+ def kb_exists(self, name: str) -> bool:
168
+ """检查知识库是否存在"""
169
+ config = self._load()
170
+ return name in config.knowledge_bases
171
+
172
+ def add_knowledge_base(
173
+ self,
174
+ name: str,
175
+ path: Path,
176
+ description: Optional[str] = None
177
+ ) -> bool:
178
+ """添加新知识库"""
179
+ config = self._load()
180
+
181
+ if name in config.knowledge_bases:
182
+ logger.warning(f"Knowledge base '{name}' already exists")
183
+ return False
184
+
185
+ resolved_path = path.expanduser().resolve()
186
+
187
+ kb = KnowledgeBaseEntry(
188
+ name=name,
189
+ path=str(resolved_path),
190
+ created=datetime.now().isoformat(),
191
+ description=description or f"Knowledge base: {name}",
192
+ )
193
+
194
+ config.knowledge_bases[name] = kb
195
+ self._config = config
196
+ return self._save()
197
+
198
+ def remove_knowledge_base(self, name: str) -> bool:
199
+ """从配置中移除知识库(不删除实际数据)"""
200
+ config = self._load()
201
+
202
+ if name not in config.knowledge_bases:
203
+ logger.warning(f"Knowledge base '{name}' not found")
204
+ return False
205
+
206
+ # 不能删除最后一个知识库
207
+ if len(config.knowledge_bases) <= 1:
208
+ logger.error("Cannot remove the last knowledge base")
209
+ return False
210
+
211
+ del config.knowledge_bases[name]
212
+
213
+ # 如果删除的是默认知识库,切换到第一个可用的
214
+ if config.default == name:
215
+ config.default = next(iter(config.knowledge_bases.keys()))
216
+
217
+ self._config = config
218
+ return self._save()
219
+
220
+ def set_default(self, name: str) -> bool:
221
+ """设置默认知识库"""
222
+ config = self._load()
223
+
224
+ if name not in config.knowledge_bases:
225
+ logger.warning(f"Knowledge base '{name}' not found")
226
+ return False
227
+
228
+ config.default = name
229
+
230
+ # 更新最后使用时间
231
+ config.knowledge_bases[name].last_used = datetime.now().isoformat()
232
+
233
+ self._config = config
234
+ return self._save()
235
+
236
+ def rename_knowledge_base(self, old_name: str, new_name: str) -> bool:
237
+ """重命名知识库"""
238
+ config = self._load()
239
+
240
+ if old_name not in config.knowledge_bases:
241
+ logger.warning(f"Knowledge base '{old_name}' not found")
242
+ return False
243
+
244
+ if new_name in config.knowledge_bases:
245
+ logger.warning(f"Knowledge base '{new_name}' already exists")
246
+ return False
247
+
248
+ kb = config.knowledge_bases[old_name]
249
+ kb.name = new_name
250
+ config.knowledge_bases[new_name] = kb
251
+ del config.knowledge_bases[old_name]
252
+
253
+ # 如果重命名的是默认知识库,更新默认设置
254
+ if config.default == old_name:
255
+ config.default = new_name
256
+
257
+ self._config = config
258
+ return self._save()
259
+
260
+ def update_last_used(self, name: str) -> bool:
261
+ """更新知识库最后使用时间"""
262
+ config = self._load()
263
+
264
+ if name in config.knowledge_bases:
265
+ config.knowledge_bases[name].last_used = datetime.now().isoformat()
266
+ self._config = config
267
+ return self._save()
268
+
269
+ return False
270
+
271
+
272
+ # 全局配置管理器实例
273
+ _global_config_manager: Optional[GlobalConfigManager] = None
274
+
275
+
276
+ def get_global_config_manager() -> GlobalConfigManager:
277
+ """获取全局配置管理器实例"""
278
+ global _global_config_manager
279
+ if _global_config_manager is None:
280
+ _global_config_manager = GlobalConfigManager()
281
+ return _global_config_manager
jfox/graph.py ADDED
@@ -0,0 +1,331 @@
1
+ """
2
+ Knowledge graph module using NetworkX for Zettelkasten notes.
3
+
4
+ Provides:
5
+ - Link graph construction from note relationships
6
+ - Graph traversal and path finding
7
+ - Hub and authority analysis
8
+ """
9
+
10
+ import os
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Set, Tuple
14
+ from dataclasses import dataclass
15
+ from datetime import datetime
16
+
17
+ import networkx as nx
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+
21
+ from .config import ZKConfig
22
+ from .models import Note, NoteType
23
+
24
+
25
+ console = Console()
26
+
27
+
28
+ @dataclass
29
+ class GraphStats:
30
+ """Statistics for the knowledge graph."""
31
+ total_nodes: int
32
+ total_edges: int
33
+ avg_degree: float
34
+ isolated_nodes: int
35
+ hubs: List[Tuple[str, int]] # (note_id, degree) pairs
36
+ clusters: int
37
+
38
+
39
+ class KnowledgeGraph:
40
+ """
41
+ Manages the knowledge graph for Zettelkasten notes.
42
+
43
+ Uses NetworkX for graph operations and provides methods for:
44
+ - Building graph from notes directory
45
+ - Finding connections between notes
46
+ - Analyzing graph structure
47
+ """
48
+
49
+ def __init__(self, config: ZKConfig):
50
+ self.config = config
51
+ self.graph = nx.DiGraph()
52
+ self._note_cache: Dict[str, Note] = {}
53
+
54
+ def build(self, force: bool = False) -> "KnowledgeGraph":
55
+ """
56
+ Build the knowledge graph from all notes.
57
+
58
+ Args:
59
+ force: If True, rebuild even if graph exists
60
+
61
+ Returns:
62
+ Self for chaining
63
+ """
64
+ if not force and len(self.graph) > 0:
65
+ return self
66
+
67
+ self.graph.clear()
68
+ self._note_cache.clear()
69
+
70
+ notes_dir = Path(self.config.notes_dir)
71
+ if not notes_dir.exists():
72
+ console.print("[yellow]Notes directory not found[/yellow]")
73
+ return self
74
+
75
+ # First pass: collect all notes
76
+ for note_file in notes_dir.rglob("*.md"):
77
+ try:
78
+ note = self._parse_note_file(note_file)
79
+ if note:
80
+ self._note_cache[note.id] = note
81
+ self.graph.add_node(
82
+ note.id,
83
+ title=note.title,
84
+ type=note.type.value,
85
+ tags=note.tags,
86
+ created=note.created.isoformat() if note.created else None,
87
+ modified=note.updated.isoformat() if note.updated else None
88
+ )
89
+ except Exception as e:
90
+ console.print(f"[red]Error parsing {note_file}: {e}[/red]")
91
+
92
+ # Second pass: add edges from frontmatter links (one directed edge per link)
93
+ for note_id, note in self._note_cache.items():
94
+ for linked_id in note.links:
95
+ if linked_id in self._note_cache:
96
+ self.graph.add_edge(note_id, linked_id, type="links")
97
+
98
+ # Third pass: extract and add wiki links from content [[...]]
99
+ for note_id, note in self._note_cache.items():
100
+ wiki_links = self._extract_wiki_links(note.content)
101
+ for link_text in wiki_links:
102
+ linked_id = self._resolve_link(link_text)
103
+ if linked_id and linked_id in self._note_cache:
104
+ # Add edge if not already exists
105
+ if not self.graph.has_edge(note_id, linked_id):
106
+ self.graph.add_edge(note_id, linked_id, type="wiki_link")
107
+
108
+ return self
109
+
110
+ def _extract_wiki_links(self, content: str) -> List[str]:
111
+ """Extract [[...]] wiki links from content."""
112
+ pattern = r'\[\[(.*?)\]\]'
113
+ matches = re.findall(pattern, content)
114
+ return [m.strip() for m in matches]
115
+
116
+ def _resolve_link(self, link_text: str) -> Optional[str]:
117
+ """Resolve a link text to a note ID."""
118
+ # First try exact ID match
119
+ if link_text in self._note_cache:
120
+ return link_text
121
+
122
+ # Then try exact title match
123
+ for note_id, note in self._note_cache.items():
124
+ if note.title.lower() == link_text.lower():
125
+ return note_id
126
+
127
+ # Finally try substring match as fallback
128
+ for note_id, note in self._note_cache.items():
129
+ if link_text.lower() in note.title.lower():
130
+ return note_id
131
+
132
+ return None
133
+
134
+ def _parse_note_file(self, file_path: Path) -> Optional[Note]:
135
+ """Parse a note file and extract metadata."""
136
+ from .note import NoteManager
137
+ return NoteManager.load_note(file_path)
138
+
139
+ def get_neighbors(self, note_id: str, direction: str = "both") -> List[str]:
140
+ """
141
+ Get neighboring notes.
142
+
143
+ Args:
144
+ note_id: The note ID
145
+ direction: "out" for outgoing, "in" for incoming, "both" for all
146
+
147
+ Returns:
148
+ List of neighboring note IDs
149
+ """
150
+ if note_id not in self.graph:
151
+ return []
152
+
153
+ neighbors = set()
154
+ if direction in ("out", "both"):
155
+ neighbors.update(self.graph.successors(note_id))
156
+ if direction in ("in", "both"):
157
+ neighbors.update(self.graph.predecessors(note_id))
158
+
159
+ return list(neighbors)
160
+
161
+ def get_path(self, source: str, target: str, max_length: int = 5) -> Optional[List[str]]:
162
+ """
163
+ Find shortest path between two notes.
164
+
165
+ Args:
166
+ source: Starting note ID
167
+ target: Target note ID
168
+ max_length: Maximum path length
169
+
170
+ Returns:
171
+ List of note IDs forming the path, or None if no path exists
172
+ """
173
+ try:
174
+ path = nx.shortest_path(self.graph, source, target)
175
+ if len(path) <= max_length + 1:
176
+ return path
177
+ return None
178
+ except (nx.NetworkXNoPath, nx.NodeNotFound):
179
+ return None
180
+
181
+ def get_related(self, note_id: str, depth: int = 2) -> Dict[str, List[str]]:
182
+ """
183
+ Get related notes at various depths.
184
+
185
+ Args:
186
+ note_id: Starting note ID
187
+ depth: How many hops to traverse
188
+
189
+ Returns:
190
+ Dictionary mapping depth to list of note IDs
191
+ """
192
+ if note_id not in self.graph:
193
+ return {}
194
+
195
+ related: Dict[str, List[str]] = {}
196
+ seen = {note_id}
197
+ current_level = {note_id}
198
+
199
+ for d in range(1, depth + 1):
200
+ next_level = set()
201
+ for node in current_level:
202
+ neighbors = set(self.graph.successors(node)) | set(self.graph.predecessors(node))
203
+ neighbors -= seen
204
+ next_level.update(neighbors)
205
+ seen.update(neighbors)
206
+
207
+ if next_level:
208
+ related[f"depth_{d}"] = list(next_level)
209
+ current_level = next_level
210
+ else:
211
+ break
212
+
213
+ return related
214
+
215
+ def find_clusters(self) -> List[Set[str]]:
216
+ """Find clusters of connected notes (weakly connected components)."""
217
+ clusters = list(nx.weakly_connected_components(self.graph))
218
+ # Filter out singletons and sort by size
219
+ clusters = [c for c in clusters if len(c) > 1]
220
+ clusters.sort(key=len, reverse=True)
221
+ return clusters
222
+
223
+ def get_hubs(self, top_n: int = 10) -> List[Tuple[str, int]]:
224
+ """
225
+ Get the most connected notes (hubs).
226
+
227
+ Degree counts both outgoing links and incoming backlinks.
228
+
229
+ Args:
230
+ top_n: Number of top hubs to return
231
+
232
+ Returns:
233
+ List of (note_id, degree) tuples
234
+ """
235
+ degrees = [
236
+ (n, self.graph.in_degree(n) + self.graph.out_degree(n))
237
+ for n in self.graph.nodes()
238
+ ]
239
+ degrees.sort(key=lambda x: x[1], reverse=True)
240
+ return degrees[:top_n]
241
+
242
+ def get_stats(self) -> GraphStats:
243
+ """Get comprehensive graph statistics."""
244
+ if len(self.graph) == 0:
245
+ return GraphStats(0, 0, 0, 0, [], 0)
246
+
247
+ total_nodes = len(self.graph)
248
+ total_edges = self.graph.number_of_edges()
249
+ avg_degree = sum(d for _, d in self.graph.degree()) / total_nodes if total_nodes > 0 else 0
250
+ isolated = len(list(nx.isolates(self.graph)))
251
+ hubs = self.get_hubs(10)
252
+ clusters = len(self.find_clusters())
253
+
254
+ return GraphStats(
255
+ total_nodes=total_nodes,
256
+ total_edges=total_edges,
257
+ avg_degree=avg_degree,
258
+ isolated_nodes=isolated,
259
+ hubs=hubs,
260
+ clusters=clusters
261
+ )
262
+
263
+ def visualize_text(self, note_id: Optional[str] = None, depth: int = 2) -> str:
264
+ """
265
+ Generate text-based visualization of the graph or subgraph.
266
+
267
+ Args:
268
+ note_id: If provided, show subgraph around this note
269
+ depth: Depth of subgraph to show
270
+
271
+ Returns:
272
+ ASCII visualization string
273
+ """
274
+ if note_id and note_id in self.graph:
275
+ nodes = {note_id}
276
+ current = {note_id}
277
+ for _ in range(depth):
278
+ next_level = set()
279
+ for n in current:
280
+ next_level.update(self.graph.successors(n))
281
+ next_level.update(self.graph.predecessors(n))
282
+ nodes.update(next_level)
283
+ current = next_level
284
+
285
+ subgraph = self.graph.subgraph(nodes)
286
+ else:
287
+ subgraph = self.graph
288
+
289
+ lines = []
290
+ lines.append(f"Graph: {len(subgraph)} nodes, {subgraph.number_of_edges()} edges")
291
+ lines.append("")
292
+
293
+ for node in subgraph.nodes():
294
+ title = subgraph.nodes[node].get("title", "Untitled")
295
+ lines.append(f"📄 {node}: {title}")
296
+
297
+ successors = list(subgraph.successors(node))
298
+ predecessors = list(subgraph.predecessors(node))
299
+
300
+ if successors:
301
+ links_str = ", ".join(successors[:5])
302
+ if len(successors) > 5:
303
+ links_str += f" (+{len(successors) - 5} more)"
304
+ lines.append(f" → Links to: {links_str}")
305
+
306
+ if predecessors:
307
+ backlinks_str = ", ".join(predecessors[:5])
308
+ if len(predecessors) > 5:
309
+ backlinks_str += f" (+{len(predecessors) - 5} more)"
310
+ lines.append(f" ← Backlinks: {backlinks_str}")
311
+
312
+ if successors or predecessors:
313
+ lines.append("")
314
+
315
+ return "\n".join(lines)
316
+
317
+ def get_orphan_notes(self) -> List[str]:
318
+ """Get notes with no links (orphans)."""
319
+ return [n for n in self.graph.nodes() if self.graph.degree(n) == 0]
320
+
321
+ def get_broken_links(self) -> Dict[str, List[str]]:
322
+ """Find notes that link to non-existent notes."""
323
+ broken = {}
324
+ for node in self.graph.nodes():
325
+ if node in self._note_cache:
326
+ for link in self._note_cache[node].links:
327
+ if link not in self._note_cache:
328
+ if node not in broken:
329
+ broken[node] = []
330
+ broken[node].append(link)
331
+ return broken