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/__init__.py +5 -0
- jfox/__main__.py +6 -0
- jfox/bm25_index.py +388 -0
- jfox/cli.py +1895 -0
- jfox/config.py +180 -0
- jfox/embedding_backend.py +65 -0
- jfox/formatters.py +305 -0
- jfox/global_config.py +281 -0
- jfox/graph.py +331 -0
- jfox/indexer.py +366 -0
- jfox/kb_manager.py +316 -0
- jfox/models.py +144 -0
- jfox/note.py +464 -0
- jfox/performance.py +408 -0
- jfox/search_engine.py +237 -0
- jfox/template.py +301 -0
- jfox/template_cli.py +327 -0
- jfox/vector_store.py +200 -0
- jfox_cli-0.1.0.dist-info/METADATA +637 -0
- jfox_cli-0.1.0.dist-info/RECORD +22 -0
- jfox_cli-0.1.0.dist-info/WHEEL +4 -0
- jfox_cli-0.1.0.dist-info/entry_points.txt +2 -0
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
|