CI / lint (push) Successful in 59s
CI / security (push) Successful in 49s
CI / test (push) Successful in 1m24s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m24s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
200 lines
7.5 KiB
Python
200 lines
7.5 KiB
Python
"""Graph data services shared by the REST route and the AI tool layer.
|
|
|
|
Builds the nodes/edges structure consumed by the graph view. The route
|
|
``/api/graph/{vault}`` and the tool ``get_graph`` both delegate here so the
|
|
graph semantics (parent/child edges, wikilinks, tag filter) stay identical.
|
|
Permission checks are the caller's responsibility.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from backend.indexer import SUPPORTED_EXTENSIONS, index
|
|
from backend.services.errors import ServiceError
|
|
from backend.services.paths import resolve_safe_path
|
|
from backend.services.vaults import get_vault_root
|
|
|
|
_WIKILINK_PATTERN = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]+)?\]\]")
|
|
|
|
|
|
def get_graph(
|
|
vault_name: str,
|
|
path: str = "",
|
|
depth: int = 1,
|
|
scope: str = "directory",
|
|
tag: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Return graph data (nodes and edges) for a vault or directory.
|
|
|
|
Args:
|
|
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 a subtree, ``"full"`` for the whole vault.
|
|
tag: Optional tag filter (only files with this tag appear).
|
|
|
|
Returns:
|
|
Dict with ``vault``, ``path``, ``scope``, ``nodes`` and ``edges``.
|
|
|
|
Raises:
|
|
ServiceError: ``not_found`` (404) when the vault or path is missing.
|
|
"""
|
|
from backend.vault_settings import get_vault_setting
|
|
|
|
vault_root = get_vault_root(vault_name)
|
|
target = resolve_safe_path(vault_root, path) if path else vault_root.resolve()
|
|
|
|
if not target.exists():
|
|
raise ServiceError(
|
|
f"Path not found: {path}",
|
|
code="not_found",
|
|
status=404,
|
|
details={"vault": vault_name, "path": path},
|
|
)
|
|
|
|
nodes: list[dict[str, Any]] = []
|
|
edges: list[dict[str, Any]] = []
|
|
node_ids: set[str] = set()
|
|
|
|
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, "tags": tags or [],
|
|
"incoming_count": incoming, "outgoing_count": outgoing,
|
|
})
|
|
return nid
|
|
|
|
def _add_edge(source: str, target_id: str, relation: str) -> None:
|
|
edges.append({"source": source, "target": target_id, "relation": relation})
|
|
|
|
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)
|
|
|
|
if scope == "full":
|
|
target = vault_root.resolve()
|
|
effective_depth = depth if depth > 0 else 2
|
|
else:
|
|
target = resolve_safe_path(vault_root, path) if path else vault_root.resolve()
|
|
effective_depth = depth
|
|
|
|
if not target.exists():
|
|
raise ServiceError(
|
|
f"Path not found: {path}",
|
|
code="not_found",
|
|
status=404,
|
|
details={"vault": vault_name, "path": path},
|
|
)
|
|
|
|
focus_name = path.split("/")[-1] if path else vault_name
|
|
focus_type = "directory" if path else "vault"
|
|
focus_id = _add_node(focus_name, focus_type, path)
|
|
|
|
def _walk_dir(dir_path: Path, parent_id: str, current_depth: int) -> None:
|
|
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("\\", "/")
|
|
|
|
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 < effective_depth:
|
|
_walk_dir(entry, did, current_depth + 1)
|
|
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
|
|
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
|
|
|
|
if target.is_dir():
|
|
_walk_dir(target, focus_id, 0)
|
|
elif target.is_file():
|
|
_walk_dir(target.parent, focus_id, 0)
|
|
|
|
_add_wikilink_edges(nodes, edges, vault_name)
|
|
|
|
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[dict[str, Any]], edges: list[dict[str, Any]], vault_name: str) -> None:
|
|
"""Add edges for wikilinks between markdown files in the current graph scope."""
|
|
file_nodes = [n for n in nodes if n["type"] == "file" and n["path"].endswith(".md")]
|
|
if len(file_nodes) < 2:
|
|
return
|
|
|
|
path_to_id = {n["path"]: n["id"] for n in file_nodes}
|
|
existing = {(e["source"], e["target"]) for e in edges}
|
|
|
|
for node in file_nodes:
|
|
vault_data = index.get(vault_name)
|
|
if not vault_data:
|
|
continue
|
|
file_entry = None
|
|
for f in vault_data.get("files", []):
|
|
if f["path"] == node["path"]:
|
|
file_entry = f
|
|
break
|
|
if not file_entry:
|
|
continue
|
|
|
|
content = file_entry.get("content", "")
|
|
if not content:
|
|
continue
|
|
|
|
for match in _WIKILINK_PATTERN.finditer(content):
|
|
target = match.group(1).strip()
|
|
target_lower = target.lower()
|
|
if not target_lower.endswith(".md"):
|
|
target_lower += ".md"
|
|
|
|
for target_path, target_id in path_to_id.items():
|
|
if target_id == node["id"]:
|
|
continue
|
|
target_name = target_path.rsplit("/", 1)[-1].lower()
|
|
if target_name == target_lower or target_path.lower() == target_lower:
|
|
edge_key = tuple(sorted([node["id"], target_id]))
|
|
if edge_key not in existing and (edge_key[1], edge_key[0]) not in existing:
|
|
edges.append({"source": node["id"], "target": target_id, "relation": "wikilink"})
|
|
existing.add((node["id"], target_id))
|
|
break
|