gen_graph.py

Table of Contents

This literate configuration file tangles to the python script gen_graph.py. The script reads the Org Roam database and extracts the node and edge information. These meta information provided in a json format is used by graph.js to present the graphical view in myzettel.

To further improve the usability and performance the graph layout is pre computed by moving the code related to ForceAtlas2 layout and Louvain community detection work from Javascript/browser to this python script.

Imports

import sqlite3
import json
import pathlib
from datetime import datetime
import re
import networkx as nx
from fa2 import ForceAtlas2
import community as community_louvain

Configuration

DB_PATH = pathlib.Path.home() / ".config" / "emacs" / "org-roam.db"
OUTPUT_PATH = pathlib.Path("/data/myhome/prabu/org/my_hugo_site/static/graph.json")


def log(msg):
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    print(f"[{timestamp}] {msg}")

Org File Data Extraction

def extract_date_from_org(file_path):
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            content = f.read(2000)
            match = re.search(
                r"^#\+date:\s*(.+)$", content, re.MULTILINE | re.IGNORECASE
            )
            if match:
                return match.group(1).strip()
    except Exception:
        pass
    return None


def extract_excerpt_from_org(file_path, max_chars=200):
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            content = f.read(3000)

            lines = content.splitlines()
            in_header = True
            excerpt_lines = []

            for line in lines:
                stripped = line.strip()
                # Skip properties drawer and metadata headers
                if in_header:
                    if stripped.startswith(":") or stripped.startswith("#+"):
                        continue
                    if stripped == "":
                        continue
                    in_header = False

                # Skip org headings
                if stripped.startswith("*"):
                    continue
                # Skip blank lines if we have nothing yet
                if stripped == "" and not excerpt_lines:
                    continue
                # Stop at first blank line after content
                if stripped == "" and excerpt_lines:
                    break

                excerpt_lines.append(stripped)

        excerpt = " ".join(excerpt_lines)
        # Strip org link syntax [[url][text]] → text, [[url]] → url
        excerpt = re.sub(r"\[\[([^\]]+)\]\[([^\]]+)\]\]", r"\2", excerpt)
        excerpt = re.sub(r"\[\[([^\]]+)\]\]", r"\1", excerpt)
        # Strip bold/italic markers
        excerpt = re.sub(r"[/*_=~]", "", excerpt)

        return excerpt[:max_chars].strip() or None
    except Exception:
        return None

Org File Filters

def extract_hugo_section(file_path):
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            content = f.read(2000)
            match = re.search(
                r"^#\+hugo_section:\s*(.+)$", content, re.MULTILINE | re.IGNORECASE
            )
            if match:
                return match.group(1).strip()
    except Exception:
        pass
    return None


def is_noexport(file_path):
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            content = f.read(2000)
            return bool(
                re.search(
                    r"^#\+filetags:.*:noexport:", content, re.MULTILINE | re.IGNORECASE
                )
            )
    except Exception:
        return False

Helper functions

Link counting

Counts backlinks and outlinks from the raw links query. Nodes stripped of org-id quote wrapping before counting.

def _count_links(links_raw):
    backlink_counts = {}
    outlink_counts = {}
    for s, d in links_raw:
        source, dest = s.strip('"'), d.strip('"')
        backlink_counts[dest] = backlink_counts.get(dest, 0) + 1
        outlink_counts[source] = outlink_counts.get(source, 0) + 1
    return backlink_counts, outlink_counts

Node filtering and building

Filters and builds the node list. Skips missing files, hugo_section nodes (non-blog content), and noexport tagged notes. Falls back to filesystem mtime when no #+date header is present.

def _build_nodes(nodes_raw, backlink_counts, outlink_counts):
    nodes = []
    node_ids = set()

    for nid, title, path in nodes_raw:
        clean_id, clean_title, clean_path = (
            nid.strip('"'),
            title.strip('"'),
            path.strip('"'),
        )
        file_path = pathlib.Path(clean_path)

        if not file_path.exists():
            continue

        hugo_section = extract_hugo_section(file_path)
        if hugo_section is not None:
            continue

        if is_noexport(file_path):
            continue

        node_ids.add(clean_id)
        date_str = extract_date_from_org(file_path)
        # Fallback to filesystem mtime
        fs_mtime = datetime.fromtimestamp(file_path.stat().st_mtime).isoformat()

        nodes.append(
            {
                "id": clean_id,
                "label": clean_title,
                "lnk": file_path.stem.lower(),
                "date": date_str or fs_mtime,
                "lastmod": fs_mtime,  # ALWAYS use actual file edit time for recency
                "backlinks": backlink_counts.get(clean_id, 0),
                "outlinks": outlink_counts.get(clean_id, 0),
                "excerpt": extract_excerpt_from_org(file_path),
            }
        )
    return nodes, node_ids

Layout computation

Computes ForceAtlas2 spatial layout and Louvain community detection. Self-loops are excluded before edge construction to avoid FA2 warnings. Node positions and community assignments are written directly into the nodes list in-place.

def _compute_layout(nodes, links):
    G = nx.Graph()
    for n in nodes:
        G.add_node(n["id"])
    for l in links:
        if l["source"] != l["target"]:
            G.add_edge(l["source"], l["target"], weight=1.2 if l["bidirectional"] else 0.8)
    fa2 = ForceAtlas2(
        outboundAttractionDistribution=False,
        edgeWeightInfluence=0.2,
        jitterTolerance=1.0,
        barnesHutOptimize=True,
        barnesHutTheta=1.2,
        scalingRatio=3.5,
        strongGravityMode=True,
        gravity=0.1,
        verbose=False,
    )
    positions = fa2.forceatlas2_networkx_layout(G, pos=None, iterations=200)
    partition = community_louvain.best_partition(G)
    community_count = len(set(partition.values()))
    for n in nodes:
        pos = positions.get(n["id"], (0.0, 0.0))
        n["x"] = round(pos[0], 4)
        n["y"] = round(pos[1], 4)
        n["community"] = partition.get(n["id"], 0)
    return community_count

Build the json

Entry point. Checks db mtime to skip unchanged runs, queries org-roam sqlite in read-only mode, builds links filtered to known node_ids, then calls helpers for counting, node building, and layout. Writes graph.json only when content has changed.

def build_json():
    if not DB_PATH.exists():
        log(f"❌ Database not found at {DB_PATH}")
        return

    db_mtime = DB_PATH.stat().st_mtime
    last_mtime_file = OUTPUT_PATH.with_suffix(".mtime")

    if last_mtime_file.exists():
        try:
            # Use >= to prevent precision-loss skips
            if float(last_mtime_file.read_text()) >= db_mtime:
                log("✓ Database unchanged, skipping")
                return
        except (ValueError, OSError):
            pass

    conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
    nodes_raw = conn.execute(
        "SELECT id, title, file FROM nodes WHERE level = 0"
    ).fetchall()
    links_raw = conn.execute(
        "SELECT source, dest FROM links WHERE type = '\"id\"'"
    ).fetchall()
    conn.close()

    backlink_counts, outlink_counts = _count_links(links_raw)
    nodes, node_ids = _build_nodes(nodes_raw, backlink_counts, outlink_counts)
    links = []
    link_set = set((s.strip('"'), d.strip('"')) for s, d in links_raw)
    for source, target in link_set:
        if source in node_ids and target in node_ids:
            links.append(
                {
                    "source": source,
                    "target": target,
                    "bidirectional": (target, source) in link_set,
                }
            )

    nodes.sort(key=lambda x: x["id"])
    links.sort(key=lambda x: (x["source"], x["target"]))

    # Compute layout
    community_count = _compute_layout(nodes, links)

    log(f"✓ Layout computed: {len(nodes)} nodes")

    should_write = True
    if OUTPUT_PATH.exists():
        try:
            with open(OUTPUT_PATH, "r") as f:
                existing = json.load(f)
                if existing.get("nodes") == nodes and existing.get("links") == links:
                    log(f"✓ No changes detected ({len(nodes)} nodes)")
                    should_write = False
        except:
            should_write = True

    if should_write:
        with open(OUTPUT_PATH, "w") as f:
            json.dump(
                {"nodes": nodes, "links": links, "communityCount": community_count},
                f,
                indent=2,
            )
        with open(last_mtime_file, "w") as f:
            f.write(str(db_mtime))

        log(f"✓ Graph updated: {len(nodes)} nodes")

Main function

if __name__ == "__main__":
    build_json()

Date: 2026-05-29 Fri 11:45

Author: Prabu Anand Kalivaradhan

Created: 2026-10-11 Sun 15:24