From 9afde65f9ccdd08db5708d5e82989cc37c47d603 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Thu, 2 Jul 2026 00:12:48 -0500 Subject: [PATCH] fix(web): strip OKF frontmatter from the note body; colour the graph by type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the 3.8.0 OKF work, in the server's web UI + graph: Fixed — the note view ran `marked` over the raw note, so 3.8.0's new leading `---` frontmatter block rendered as a horizontal rule + the frontmatter keys as stray text atop every note. `renderMarkdown` now strips the leading frontmatter block before rendering (it's already surfaced in the structured header). No Python test caught this — it's pure client-side rendering — so verified with node. Changed — the knowledge graph now colours nodes by OKF `type` instead of by degree (node SIZE still encodes degree), with a legend built from the types present. graph.py: GraphNode carries `okf_type` (derived the same way render does), `to_json` emits a `type` per node (so /api/graph + `graph export --json` carry it), and `to_dot` fills nodes by type. graph.js: type→colour map + type legend. ruff + mypy clean; full suite 742 passed (+3 graph tests). JS syntax-checked with `node --check`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JFuGddPx3UhUkcC74KBXZB --- CHANGELOG.md | 6 +++++ src/omind/graph.py | 48 ++++++++++++++++++++++++++++------- src/omind/web/static/app.js | 7 ++++- src/omind/web/static/graph.js | 29 +++++++++++---------- tests/test_graph.py | 24 ++++++++++++++++++ 5 files changed, 91 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a7e4dd..681bab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/omind/graph.py b/src/omind/graph.py index d009a32..d369799 100644 --- a/src/omind/graph.py +++ b/src/omind/graph.py @@ -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: @@ -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 @@ -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")): @@ -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: @@ -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( @@ -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)};") diff --git a/src/omind/web/static/app.js b/src/omind/web/static/app.js index e536592..c1908ed 100644 --- a/src/omind/web/static/app.js +++ b/src/omind/web/static/app.js @@ -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
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"; diff --git a/src/omind/web/static/graph.js b/src/omind/web/static/graph.js index 79214a9..373cf88 100644 --- a/src/omind/web/static/graph.js +++ b/src/omind/web/static/graph.js @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 || {}; @@ -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 }; }); @@ -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 = '
' + '
' + ' ' + nodes.length + " notes · " + edges.length + " links" + ' ' + - ' hub' + - ' linked' + - ' orphan' + + presentTypes.map((t) => '' + esc(t)).join("") + " " + ' ' + "
" + @@ -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(); diff --git a/tests/test_graph.py b/tests/test_graph.py index 8549677..42c8678 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -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