feat(graph): Phase 1+2 — full-vault, tag filter, backlinks, tooltips, depth slider
Backend (main.py): - GraphNode: added tags, incoming_count, outgoing_count - GraphEdge: added 'backlink' relation - GraphResponse: added 'scope' field - api_graph: scope=full|directory, tag= filter, backlinks - Full-vault tree walk with configurable depth 0-3 - Tag index from in-memory file index for fast filtering - Incoming/outgoing link count per node Frontend (graph.js + index.html): - Theme-adaptive colors via CSS custom properties - Depth slider (0-3) with live reload - Full-vault toggle button (🌐 Tout / 📁 Dossier) - Search input with tag filtering + visual highlighting - Tooltip on hover (name, path, tags, link counts) - Backlink edges rendered in red dashed - Node size proportional to link count - Larger modal (1000px, 85vh)
This commit is contained in:
+66
-8
@@ -229,19 +229,23 @@ class GraphNode(BaseModel):
|
||||
type: str = Field(description="'vault', 'directory', or 'file'")
|
||||
path: str = Field(description="Relative path within vault")
|
||||
size: int = Field(default=0, description="File size in bytes")
|
||||
tags: List[str] = Field(default_factory=list, description="Tags from frontmatter")
|
||||
incoming_count: int = Field(default=0, description="Number of incoming wikilinks")
|
||||
outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks")
|
||||
|
||||
|
||||
class GraphEdge(BaseModel):
|
||||
"""An edge between two nodes in the graph view."""
|
||||
source: str = Field(description="Source node ID")
|
||||
target: str = Field(description="Target node ID")
|
||||
relation: str = Field(description="'parent' or 'wikilink'")
|
||||
relation: str = Field(description="'parent', 'wikilink', or 'backlink'")
|
||||
|
||||
|
||||
class GraphResponse(BaseModel):
|
||||
"""Graph data for a vault or directory."""
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Root path for the graph")
|
||||
scope: str = Field(default="directory", description="'directory' or 'full'")
|
||||
nodes: List[GraphNode] = Field(description="Graph nodes (files and directories)")
|
||||
edges: List[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)")
|
||||
|
||||
@@ -2165,6 +2169,8 @@ async def api_graph(
|
||||
vault_name: str,
|
||||
path: str = Query("", description="Relative path to focus on"),
|
||||
depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"),
|
||||
scope: str = Query("directory", description="'directory' (default) or 'full' for entire vault"),
|
||||
tag: str = Query("", description="Filter: only show files with this tag"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return graph data (nodes and edges) for a vault or directory.
|
||||
@@ -2176,6 +2182,8 @@ async def api_graph(
|
||||
vault_name: Name of the vault.
|
||||
path: Relative directory path to focus on (empty = root).
|
||||
depth: Expansion depth (0 = only direct children, 1-3 = deeper).
|
||||
scope: 'directory' for subtree, 'full' for entire vault.
|
||||
tag: Optional tag filter (only files with this tag appear).
|
||||
|
||||
Returns:
|
||||
``GraphResponse`` with nodes and edges.
|
||||
@@ -2197,11 +2205,16 @@ async def api_graph(
|
||||
edges: List[dict] = []
|
||||
node_ids: set = set()
|
||||
|
||||
def _add_node(name: str, ntype: str, npath: str, size: int = 0) -> str:
|
||||
def _add_node(name: str, ntype: str, npath: str, size: int = 0,
|
||||
tags: list[str] | None = None, incoming: int = 0, outgoing: int = 0) -> str:
|
||||
nid = f"{vault_name}:{npath}"
|
||||
if nid not in node_ids:
|
||||
node_ids.add(nid)
|
||||
nodes.append({"id": nid, "name": name, "type": ntype, "path": npath, "size": size})
|
||||
nodes.append({
|
||||
"id": nid, "name": name, "type": ntype, "path": npath,
|
||||
"size": size, "tags": tags or [],
|
||||
"incoming_count": incoming, "outgoing_count": outgoing,
|
||||
})
|
||||
return nid
|
||||
|
||||
def _add_edge(source: str, target: str, relation: str):
|
||||
@@ -2212,6 +2225,26 @@ async def api_graph(
|
||||
settings = get_vault_setting(vault_name) or {}
|
||||
hide_hidden = settings.get("hideHiddenFiles", False)
|
||||
|
||||
# Build tag index from the in-memory index for fast lookups
|
||||
_tag_index: dict[str, list[str]] = {}
|
||||
for doc_key, info in index.items():
|
||||
vn, fp = doc_key.split("::", 1) if "::" in doc_key else ("", "")
|
||||
if vn == vault_name:
|
||||
for t in info.get("tags", []):
|
||||
_tag_index.setdefault(t.lower(), []).append(fp)
|
||||
|
||||
# Determine scope
|
||||
if scope == "full":
|
||||
# Full vault — walk entire vault root, ignore path param
|
||||
target = vault_root.resolve()
|
||||
effective_depth = depth if depth > 0 else 2 # minimum depth 2 for full view
|
||||
else:
|
||||
target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve()
|
||||
effective_depth = depth
|
||||
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
|
||||
|
||||
# Add the focus node
|
||||
focus_name = path.split("/")[-1] if path else vault_name
|
||||
focus_type = "directory" if path else "vault"
|
||||
@@ -2219,20 +2252,28 @@ async def api_graph(
|
||||
|
||||
# Walk directory tree up to depth levels
|
||||
def _walk_dir(dir_path: Path, parent_id: str, current_depth: int):
|
||||
if current_depth > depth:
|
||||
if current_depth > effective_depth:
|
||||
return
|
||||
try:
|
||||
for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
if hide_hidden and entry.name.startswith("."):
|
||||
continue
|
||||
rel = str(entry.relative_to(vault_root)).replace("\\", "/")
|
||||
|
||||
# Tag filter: skip files that don't have the requested tag
|
||||
if tag and entry.is_file():
|
||||
file_tags = [t.lower() for t in _tag_index.get(rel, [])]
|
||||
if tag.lower() not in file_tags:
|
||||
continue
|
||||
|
||||
if entry.is_dir():
|
||||
did = _add_node(entry.name, "directory", rel)
|
||||
_add_edge(parent_id, did, "parent")
|
||||
if current_depth < depth:
|
||||
if current_depth < effective_depth:
|
||||
_walk_dir(entry, did, current_depth + 1)
|
||||
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
|
||||
fid = _add_node(entry.name, "file", rel, entry.stat().st_size)
|
||||
file_tags = _tag_index.get(rel, [])
|
||||
fid = _add_node(entry.name, "file", rel, entry.stat().st_size, tags=file_tags)
|
||||
_add_edge(parent_id, fid, "parent")
|
||||
except PermissionError:
|
||||
pass
|
||||
@@ -2240,13 +2281,30 @@ async def api_graph(
|
||||
if target.is_dir():
|
||||
_walk_dir(target, focus_id, 0)
|
||||
elif target.is_file():
|
||||
# For a single file, show siblings in same directory
|
||||
_walk_dir(target.parent, focus_id, 0)
|
||||
|
||||
# Add wikilink edges between markdown files in the current scope
|
||||
_add_wikilink_edges(nodes, edges, node_ids, vault_name)
|
||||
|
||||
return {"vault": vault_name, "path": path, "nodes": nodes, "edges": edges}
|
||||
# Compute incoming/outgoing counts from edges
|
||||
edge_counts: dict[str, dict[str, int]] = {}
|
||||
for node in nodes:
|
||||
edge_counts[node["id"]] = {"incoming": 0, "outgoing": 0}
|
||||
for edge in edges:
|
||||
if edge["relation"] in ("wikilink", "backlink"):
|
||||
src = edge["source"]
|
||||
tgt = edge["target"]
|
||||
if src in edge_counts:
|
||||
edge_counts[src]["outgoing"] += 1
|
||||
if tgt in edge_counts:
|
||||
edge_counts[tgt]["incoming"] += 1
|
||||
for node in nodes:
|
||||
counts = edge_counts.get(node["id"], {"incoming": 0, "outgoing": 0})
|
||||
node["incoming_count"] = counts["incoming"]
|
||||
node["outgoing_count"] = counts["outgoing"]
|
||||
|
||||
return {"vault": vault_name, "path": path, "scope": scope,
|
||||
"nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
def _add_wikilink_edges(nodes: list, edges: list, node_ids: set, vault_name: str):
|
||||
|
||||
Reference in New Issue
Block a user