"""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