Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **Web UI rendered the OKF frontmatter block as body text**: after 3.8.0 gave every note a leading YAML frontmatter block, the note view ran `marked` over the raw note, so the `---` block surfaced as a horizontal rule followed by the frontmatter keys at the top of every note. `renderMarkdown` now strips the leading frontmatter block before rendering — it's already surfaced in the structured header, not body prose.

### Changed
- **The knowledge graph is now coloured by OKF `type`** (web view + `omind graph export`): each distinct `type` present gets a stable colour with a legend built from it; node *size* still encodes link degree. The Graphviz/DOT export fills nodes by type, and the graph JSON (`/api/graph`, `graph export --json`) now carries a `type` per node.

## [3.8.0] - 2026-07-01

### Added
Expand Down
48 changes: 39 additions & 9 deletions src/omind/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from pathlib import Path

from omind.paths import RESERVED_FILENAMES
from omind.store import _WIKILINK_RE, parse_note
from omind.store import _WIKILINK_RE, derive_okf_type, parse_note


def _link_target(raw: str) -> str:
Expand All @@ -40,6 +40,7 @@ class GraphNode:

filename: str # "Foo.md"
title: str
okf_type: str = "" # the note's OKF ``type`` — for grouping/colouring the graph
out: set[str] = field(default_factory=set) # filenames this note links to
inn: set[str] = field(default_factory=set) # filenames that link to this note

Expand Down Expand Up @@ -71,7 +72,7 @@ def build_graph(omi_dir: Path | str) -> Graph:
omi = Path(omi_dir)
# (filename, title, raw outbound targets) for each live note, plus an index
# from every linkable identifier (stem + title, lowercased) to its filename.
parsed: list[tuple[str, str, set[str]]] = []
parsed: list[tuple[str, str, str, set[str]]] = []
id_to_file: dict[str, str] = {}
if omi.is_dir():
for path in sorted(omi.glob("*.md")):
Expand All @@ -85,15 +86,21 @@ def build_graph(omi_dir: Path | str) -> Graph:
if fields.disabled:
continue
title = fields.title.strip()
# The note's OKF type, deriving it from tags when undeclared — the
# same rule render_fields uses — so every node carries a non-empty type.
okf_type = fields.okf_type.strip() or derive_okf_type(fields.tags)
targets = {t for t in (_link_target(m) for m in _WIKILINK_RE.findall(text)) if t}
parsed.append((path.name, title, targets))
parsed.append((path.name, title, okf_type, targets))
id_to_file[path.stem.strip().lower()] = path.name
if title:
id_to_file[title.lower()] = path.name

nodes = {fn: GraphNode(filename=fn, title=title) for fn, title, _ in parsed}
nodes = {
fn: GraphNode(filename=fn, title=title, okf_type=okf_type)
for fn, title, okf_type, _ in parsed
}
dangling: list[tuple[str, str]] = []
for src, _title, targets in parsed:
for src, _title, _type, targets in parsed:
for target in sorted(targets):
dest = id_to_file.get(target.lower())
if dest is None:
Expand Down Expand Up @@ -189,7 +196,12 @@ def to_json(graph: Graph) -> dict[str, object]:
"""A JSON-serialisable view of the whole graph (nodes, edges, dangling)."""
return {
"nodes": [
{"id": node.filename, "title": node.title, "out": sorted(node.out)}
{
"id": node.filename,
"title": node.title,
"type": node.okf_type,
"out": sorted(node.out),
}
for node in sorted(graph.nodes.values(), key=lambda n: n.filename.lower())
],
"edges": sorted(
Expand All @@ -204,12 +216,30 @@ def _dot_quote(text: str) -> str:
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'


#: Pale fill per OKF ``type`` for the DOT/SVG export (and a hint for the web
#: legend). Unknown/producer-defined types fall back to :data:`_DEFAULT_TYPE_FILL`.
TYPE_FILL = {
"User": "#dbeafe",
"Feedback": "#fce7f3",
"Project": "#dcfce7",
"Reference": "#fef9c3",
"Memory": "#e5e7eb",
"Playbook": "#ede9fe",
"Reference Card": "#ffedd5",
}
_DEFAULT_TYPE_FILL = "#f3f4f6"


def to_dot(graph: Graph) -> str:
"""The graph as Graphviz DOT (``dot -Tsvg``-renderable)."""
lines = ["digraph omi {", " rankdir=LR;", " node [shape=box];"]
"""The graph as Graphviz DOT (``dot -Tsvg``-renderable), filled by OKF type."""
lines = ["digraph omi {", " rankdir=LR;", " node [shape=box, style=filled];"]
for node in sorted(graph.nodes.values(), key=lambda n: n.filename.lower()):
label = node.title or node.filename[:-3]
lines.append(f" {_dot_quote(node.filename)} [label={_dot_quote(label)}];")
fill = TYPE_FILL.get(node.okf_type, _DEFAULT_TYPE_FILL)
lines.append(
f" {_dot_quote(node.filename)} "
f"[label={_dot_quote(label)}, fillcolor={_dot_quote(fill)}];"
)
for src in sorted(graph.nodes):
for dst in sorted(graph.nodes[src].out):
lines.append(f" {_dot_quote(src)} -> {_dot_quote(dst)};")
Expand Down
7 changes: 6 additions & 1 deletion src/omind/web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,12 @@ function noteByName(name) {

// Turn [[wikilinks]] and #tags into markup, then render markdown.
function renderMarkdown(md) {
let src = md.replace(/\[\[([^\]]+)\]\]/g, (_, name) => {
// Strip a leading YAML frontmatter block (--- … ---): it is the note's OKF
// metadata (type/title/tags/…), surfaced in the structured header above — not
// body prose. Without this, marked renders the `---` as an <hr> and the
// frontmatter keys as a run of stray text at the top of every note.
const body = md.replace(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/, "");
let src = body.replace(/\[\[([^\]]+)\]\]/g, (_, name) => {
const target = name.trim();
const exists = !!noteByName(target);
const cls = exists ? "wikilink" : "wikilink missing";
Expand Down
29 changes: 16 additions & 13 deletions src/omind/web/static/graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
const cssVar = (name, fallback) =>
(getComputedStyle(document.documentElement).getPropertyValue(name) || fallback).trim();

// Degree -> color tier (accent = hubs, link = mid, faint = leaf, red = orphan).
function paletteFor(deg, theme) {
if (deg === 0) return "#ff5d5d";
if (deg >= 8) return theme.accent;
if (deg >= 4) return theme.link;
if (deg >= 2) return theme.soft;
return theme.faint;
}
const esc = (s) =>
String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));

// Categorical colours for OKF `type` — vivid enough to read on the dark canvas.
// Distinct types present get a stable colour by sorted order; node SIZE still
// encodes degree, so colour is free to carry the note's kind.
const TYPE_PALETTE = ["#7d9bff", "#5fd0bf", "#f78fb3", "#9ae66e", "#ffd166",
"#c792ea", "#ff8b5d", "#4fc3f7", "#e6e6e6", "#b0bec5"];

async function render(container, opts) {
opts = opts || {};
Expand All @@ -27,7 +27,7 @@
const index = new Map();
const nodes = data.nodes.map((n, i) => {
index.set(n.id, i);
return { id: n.id, title: n.title || n.id.replace(/\.md$/i, ""), deg: 0,
return { id: n.id, title: n.title || n.id.replace(/\.md$/i, ""), type: n.type || "", deg: 0,
x: Math.cos(i) * 240 + (i % 7) * 13, y: Math.sin(i) * 240 + (i % 5) * 11,
vx: 0, vy: 0, pinned: false };
});
Expand All @@ -41,15 +41,18 @@
const neighbors = nodes.map(() => new Set());
for (const [a, b] of edges) { neighbors[a].add(b); neighbors[b].add(a); }

// OKF type -> colour: one stable colour per distinct type present.
const presentTypes = [...new Set(nodes.map((n) => n.type).filter(Boolean))].sort();
const typeColor = new Map(presentTypes.map((t, i) => [t, TYPE_PALETTE[i % TYPE_PALETTE.length]]));
const colorFor = (n) => typeColor.get(n.type) || cssVar("--text-faint", "#626c7d");

// --- canvas + chrome ---------------------------------------------------
container.innerHTML =
'<div class="graph-wrap">' +
' <div class="graph-bar">' +
' <span class="graph-stat">' + nodes.length + " notes · " + edges.length + " links</span>" +
' <span class="graph-legend">' +
' <i style="background:' + cssVar("--accent", "#7d9bff") + '"></i>hub' +
' <i style="background:' + cssVar("--link", "#5fd0bf") + '"></i>linked' +
' <i style="background:#ff5d5d"></i>orphan' +
presentTypes.map((t) => '<i style="background:' + typeColor.get(t) + '"></i>' + esc(t)).join("") +
" </span>" +
' <button type="button" class="graph-reset btn">reset view</button>' +
" </div>" +
Expand Down Expand Up @@ -158,7 +161,7 @@
const n = nodes[i];
const dim = hover !== -1 && hover !== i && !neighbors[hover].has(i);
ctx.globalAlpha = dim ? 0.25 : 1;
ctx.fillStyle = paletteFor(n.deg, theme);
ctx.fillStyle = colorFor(n);
ctx.beginPath();
ctx.arc(sx(n.x), sy(n.y), radius(n) * (i === hover ? 1.5 : 1), 0, 6.2832);
ctx.fill();
Expand Down
24 changes: 24 additions & 0 deletions tests/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,27 @@ def test_empty_vault_is_an_empty_graph(tmp_path: Path) -> None:
g = graph.build_graph(tmp_path / "OMI") # directory does not exist
assert g.nodes == {}
assert graph.stats(g) == {"notes": 0, "links": 0, "orphans": 0, "dangling": 0}


def test_build_graph_carries_okf_type(store: OmiStore) -> None:
store.create_note(NoteFields(title="Fb", summary="s", tags=["feedback"]))
store.create_note(NoteFields(title="Plain", summary="s")) # untagged
g = graph.build_graph(store.omi_dir)
assert g.nodes["Fb.md"].okf_type == "Feedback" # derived from #feedback
assert g.nodes["Plain.md"].okf_type == "Memory" # default when unmapped


def test_to_json_includes_type(store: OmiStore) -> None:
store.create_note(NoteFields(title="Ref", summary="s", tags=["reference"]))
data = graph.to_json(graph.build_graph(store.omi_dir))
nodes = data["nodes"]
assert isinstance(nodes, list)
node = next(n for n in nodes if n["id"] == "Ref.md")
assert node["type"] == "Reference"


def test_to_dot_fills_nodes_by_type(store: OmiStore) -> None:
store.create_note(NoteFields(title="Ref", summary="s", tags=["reference"]))
dot = graph.to_dot(graph.build_graph(store.omi_dir))
assert "style=filled" in dot
assert graph.TYPE_FILL["Reference"] in dot # Reference nodes carry their fill
Loading