This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/DESIGN-0.17.3.md
2026-02-28 15:20:23 +00:00

35 KiB

DESIGN: Notes Rich Text Editor, Obsidian-Style Linking & Knowledge Graph

Version: v0.17.3 Status: Draft Scope: Upgrade the notes editor from a plain <textarea> to a CM6-powered markdown editor with live preview, [[wikilink]] resolution, a backlinks system, Canvas-rendered force-directed graph visualization, transclusion, note-from-selection (chat → notes bridge), and daily notes.

Depends on: v0.17.2 (CodeMirror 6 bundle provides CM.codeEditor() and the markdown language mode).


Motivation

The notes system (v0.9.3 side panel, CRUD, folders, search) stores markdown but edits it as raw text in a <textarea>. Now that CM6 is bundled, we can give notes the same live-preview markdown experience that the chat input gets — plus something the chat input doesn't need: inter-note linking.

The Obsidian model is the right reference: markdown files with [[wikilinks]] resolved at render time, bidirectional backlinks, and create-on-reference. Our advantage over Obsidian: we already have semantic search via embeddings (v0.14.0 KB infrastructure), so link autocomplete can be smarter than filename matching.

Beyond linking, the notes system sits adjacent to the chat system but has no bridge between them. Users accumulate valuable AI responses in conversations that they then manually copy into notes. Note-from-selection closes that gap. A visual graph makes the link topology navigable and discoverable, turning notes from a flat list into a connected knowledge base.


Architecture

New CM6 Factory: CM.noteEditor()

A third factory alongside chatInput() and codeEditor(), tailored for long-form markdown editing in the notes panel.

CM.noteEditor(target, {
    value: '',                    // initial markdown content
    darkMode: true,
    onChange: (text) => {},        // live content callback
    onLink: (title) => {},        // [[link]] activated callback
    linkCompleter: async (query) => [], // fuzzy note search for [[ autocomplete
});

Behavior:

  • Full markdown syntax highlighting (headings, bold, italic, code, lists, links)
  • Live inline preview: headings render at size, bold/italic styled, code blocks highlighted — the document is still markdown but looks formatted
  • [[ triggers autocomplete dropdown of existing notes (fuzzy search)
  • [[Note Title]] tokens rendered as clickable chips (CM6 Decoration.widget)
  • ![[Note Title]] recognized as transclusion markers (decoration differs from regular links — see Transclusion section)
  • Line numbers off (it's a note, not code)
  • Spell check enabled (EditorView.contentAttributes: { spellcheck: 'true' })
  • Vim/Emacs keybindings respected (user preference from v0.17.2)

Standard double-bracket syntax with optional display text:

[[Note Title]]              → links to note titled "Note Title"
[[Note Title|display text]] → shows "display text", links to "Note Title"
![[Note Title]]             → embeds target note content inline (transclusion)

Resolution order:

  1. Exact title match (case-insensitive)
  2. Fuzzy title match (for autocomplete, not for rendering)
  3. Unresolved → rendered as red chip with "create" affordance

Bundle Addition

The note editor factory lives in a new file in src/editor/:

src/editor/
  index.mjs           # existing — adds noteEditor to window.CM
  chat-input.mjs      # existing
  code-editor.mjs     # existing
  note-editor.mjs     # NEW — noteEditor() factory
  wikilink.mjs        # NEW — CM6 extension: parse, decorate, autocomplete [[links]]
  theme.mjs           # existing

The wikilink extension is CM6-native: a ViewPlugin that scans for [[...]] patterns and replaces them with Decoration.widget nodes (clickable chips). The autocomplete uses CM6's autocompletion with a custom completeFromList source triggered by [[.

No bundle size concern — the wikilink plugin is tiny custom code, not an external dependency.


Data Model

Tracks directed links between notes, extracted on save.

-- Postgres
CREATE TABLE IF NOT EXISTS note_links (
    source_note_id  UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
    target_note_id  UUID REFERENCES notes(id) ON DELETE CASCADE,
    target_title    TEXT NOT NULL,           -- raw [[title]] text, for unresolved links
    display_text    TEXT,                    -- optional |alias
    is_transclusion BOOLEAN NOT NULL DEFAULT FALSE,
    created_at      TIMESTAMPTZ DEFAULT now(),
    PRIMARY KEY (source_note_id, target_title)
);

CREATE INDEX idx_note_links_target ON note_links(target_note_id)
    WHERE target_note_id IS NOT NULL;

-- SQLite
CREATE TABLE IF NOT EXISTS note_links (
    source_note_id  TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
    target_note_id  TEXT REFERENCES notes(id) ON DELETE CASCADE,
    target_title    TEXT NOT NULL,
    display_text    TEXT,
    is_transclusion INTEGER NOT NULL DEFAULT 0,
    created_at      TEXT DEFAULT (datetime('now')),
    PRIMARY KEY (source_note_id, target_title)
);

CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target_note_id)
    WHERE target_note_id IS NOT NULL;

Key design decisions:

  • target_note_id is nullable — an unresolved link (target note doesn't exist yet) stores the title but has NULL for the FK. When a note with that title is created, a background pass resolves dangling links.
  • Primary key is (source_note_id, target_title) — a note can only link to the same title once (last one wins if duplicated).
  • target_title is always stored for human readability and re-resolution after renames.
  • is_transclusion distinguishes ![[embed]] from [[link]] — useful for graph rendering (different edge style) and for knowing which links trigger content embedding in read mode.

When a note is saved, the backend:

  1. Parses content for [[...]] and ![[...]] patterns
  2. Resolves each title to a note ID: SELECT id FROM notes WHERE LOWER(title) = LOWER($1) AND user_id = $2
  3. Deletes existing links for this source note: DELETE FROM note_links WHERE source_note_id = $1
  4. Inserts fresh links with resolved (or NULL) target IDs

This full-replace approach is simple and correct — notes don't have thousands of links, so the cost is negligible.

SELECT n.id, n.title, n.folder_path, n.updated_at, nl.display_text
FROM note_links nl
JOIN notes n ON n.id = nl.source_note_id
WHERE nl.target_note_id = $1
ORDER BY n.updated_at DESC;

When a new note is created, resolve any dangling links pointing at its title:

UPDATE note_links
SET target_note_id = $1
WHERE target_note_id IS NULL
AND LOWER(target_title) = LOWER($2)
AND source_note_id IN (SELECT id FROM notes WHERE user_id = $3);

This runs once on note creation — cheap and ensures links "light up" retroactively.

Graph Data Query

The graph endpoint returns all notes and resolved links for a user in a single payload. This is the full graph — filtering/clustering happens client-side.

-- Nodes: all user's notes (lightweight — no content)
SELECT id, title, folder_path, tags, updated_at
FROM notes
WHERE user_id = $1
ORDER BY updated_at DESC;

-- Edges: all resolved links
SELECT nl.source_note_id, nl.target_note_id, nl.target_title,
       nl.display_text, nl.is_transclusion
FROM note_links nl
JOIN notes n ON n.id = nl.source_note_id
WHERE n.user_id = $1
AND nl.target_note_id IS NOT NULL;

For users with large note collections (1000+), the graph endpoint supports pagination via limit/offset on nodes, though the initial implementation fetches all. The payload is small — ~200 bytes per node, ~100 bytes per edge, so 1000 notes with 2000 links is ~400KB uncompressed.


API Changes

Existing Endpoints (modified)

PUT /api/v1/notes/:id — after updating note content, re-extract links. No API signature change; link extraction is a server-side side effect.

POST /api/v1/notes — after creating a note, extract links AND resolve dangling links from other notes that reference this title. Accepts optional source_channel_id and source_message_id for provenance tracking (used by note-from-selection).

New Endpoints

GET /api/v1/notes/:id/backlinks

Returns notes that link to this note.

{
    "data": [
        {
            "id": "uuid",
            "title": "Meeting Notes 2026-02-28",
            "folder_path": "/work",
            "updated_at": "2026-02-28T10:00:00Z",
            "display_text": null
        }
    ]
}

GET /api/v1/notes/search-titles?q=<query>&limit=10

Lightweight fuzzy title search for the [[ autocomplete. Returns titles and IDs only — no content, no embeddings. Fast path.

{
    "data": [
        { "id": "uuid", "title": "Project Kickoff Notes" },
        { "id": "uuid", "title": "Project Architecture" }
    ]
}

Uses ILIKE on Postgres, LIKE on SQLite (case-insensitive via COLLATE). The semantic search endpoint (GET /api/v1/notes/search) remains available for deeper searches but is overkill for autocomplete.

GET /api/v1/notes/graph

Returns the full graph topology for the current user.

{
    "nodes": [
        {
            "id": "uuid",
            "title": "Project Architecture",
            "folder_path": "/work",
            "tags": ["architecture", "design"],
            "updated_at": "2026-02-28T10:00:00Z",
            "link_count": 5
        }
    ],
    "edges": [
        {
            "source": "uuid-1",
            "target": "uuid-2",
            "title": "Project Architecture",
            "is_transclusion": false
        }
    ],
    "unresolved": [
        {
            "source": "uuid-1",
            "title": "Nonexistent Note"
        }
    ]
}

link_count is inbound + outbound — used for node sizing in the graph. unresolved is included so the graph can optionally show dangling references as ghost nodes.


Frontend Integration

Note Editor Swap

In notes.js, openNoteEditor() currently sets .value on the #noteEditorContent textarea. After v0.17.3:

// Replace textarea with CM6 note editor
const container = document.getElementById('noteEditorContentContainer');
const editor = CM.noteEditor(container, {
    value: note.content || '',
    darkMode: document.body.classList.contains('dark-theme'),
    onChange: (text) => {
        // Could auto-save or mark dirty
    },
    onLink: (title) => {
        // Navigate to linked note
        const linked = _findNoteByTitle(title);
        if (linked) {
            openNoteEditor(linked.id);
        } else {
            // Offer to create
            _createNoteFromLink(title);
        }
    },
    linkCompleter: async (query) => {
        const resp = await API.searchNoteTitles(query, 10);
        return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
    },
});

The HTML changes from:

<textarea id="noteEditorContent" rows="12" ...></textarea>

To:

<div id="noteEditorContentContainer" class="notes-content-input"></div>

With graceful fallback: if window.CM?.noteEditor is undefined, create a <textarea> inside the container (same behavior as today).

Below the note editor, a collapsible "Backlinks" section:

<div id="noteBacklinks" class="note-backlinks" style="display:none">
    <div class="note-backlinks-header" onclick="toggleBacklinks()">
        <span>Linked mentions</span>
        <span id="noteBacklinksCount" class="badge">0</span>
    </div>
    <div id="noteBacklinksList" class="note-backlinks-list"></div>
</div>

Loaded on openNoteEditor() for existing notes:

async function loadBacklinks(noteId) {
    const resp = await API.getNoteBacklinks(noteId);
    const links = resp.data || [];
    const el = document.getElementById('noteBacklinksList');
    const count = document.getElementById('noteBacklinksCount');
    count.textContent = links.length;
    document.getElementById('noteBacklinks').style.display =
        links.length > 0 ? '' : 'none';
    el.innerHTML = links.map(n => `
        <div class="note-backlink-item" onclick="openNoteEditor('${n.id}')">
            <span class="note-backlink-title">${esc(n.title)}</span>
            <span class="note-backlink-folder text-muted">${esc(n.folder_path)}</span>
        </div>
    `).join('');
}

Note Preview (read mode)

The existing note list view shows raw markdown. After this release, [[Note Title]] patterns in rendered markdown (via marked.js) become clickable links. A small marked.js extension or post-render pass converts [[...]] to <a> tags with onclick handlers.

Transclusion in Read Mode

![[Note Title]] in rendered markdown embeds the target note's content inline. Implementation is a post-render pass in _showNoteReadMode() and _showNotePreview():

async function _renderTransclusions(containerEl) {
    const markers = containerEl.querySelectorAll('.wikilink-transclusion');
    for (const marker of markers) {
        const title = marker.dataset.title;
        try {
            const note = await _findNoteByTitle(title);
            if (!note) {
                marker.innerHTML = `<div class="transclusion-missing">
                    Note not found: ${esc(title)}</div>`;
                continue;
            }
            const content = (await API.getNote(note.id)).content || '';
            marker.innerHTML = `
                <div class="transclusion-embed" data-note-id="${note.id}">
                    <div class="transclusion-header" onclick="openNoteEditor('${note.id}')">
                        <span class="transclusion-icon">↗</span>
                        <span class="transclusion-title">${esc(note.title)}</span>
                    </div>
                    <div class="transclusion-content">${formatMessage(content)}</div>
                </div>`;
        } catch (e) {
            marker.innerHTML = `<div class="transclusion-error">
                Failed to load: ${esc(title)}</div>`;
        }
    }
}

The marked.js extension (or post-render regex pass) converts ![[Title]] to <span class="wikilink-transclusion" data-title="Title"></span> placeholder elements, then _renderTransclusions() fills them asynchronously.

Recursion guard: Transclusions do not recurse. If note A embeds note B and note B embeds note A, the second-level embed renders as a link chip, not a nested embed. The render function tracks a Set of already-embedded IDs.

Edit mode: In the CM6 editor, ![[Title]] renders as a distinct chip (e.g., with an embed icon) but does not inline the content. Inline expansion is read-mode only — editing transcluded content requires navigating to the source note.


Graph Visualization

Approach: Canvas + Force Simulation

The graph uses HTML5 Canvas for rendering with a custom force-directed layout. No external dependencies — the force simulation is straightforward to implement in ~200 lines and avoids adding d3-force (~30KB) for a single use case.

The graph is lazy-loaded: the module (src/js/note-graph.js) and its Canvas setup only execute when the user opens the graph view.

Force Simulation

A simple velocity Verlet integration with three forces:

// Force parameters (tunable)
const SIM = {
    repulsion:    1000,   // charge repulsion constant
    attraction:   0.005,  // spring constant for linked nodes
    edgeLength:   120,    // rest length of link springs
    damping:      0.9,    // velocity damping per tick
    centerGravity: 0.01,  // pull toward canvas center
    maxVelocity:  10,     // velocity cap prevents explosions
    ticksPerFrame: 3,     // sub-steps per rAF for convergence speed
};

Forces applied per tick:

  1. Repulsion (all pairs): Coulomb-like F = k / d² between every node pair. For graphs under ~500 nodes, brute-force O(n²) is fine at 60fps. If performance becomes an issue, a Barnes-Hut approximation (quadtree spatial index) can be added later.

  2. Attraction (linked pairs): Hooke's law spring along each edge. F = k * (d - restLength) pulls linked nodes toward edgeLength.

  3. Center gravity: Gentle pull toward canvas center prevents drift. F = centerGravity * (center - position).

  4. Damping: Multiply velocity by damping each tick for convergence.

function tick(nodes, edges) {
    // Reset accelerations
    for (const n of nodes) { n.ax = 0; n.ay = 0; }

    // 1. Repulsion (all pairs)
    for (let i = 0; i < nodes.length; i++) {
        for (let j = i + 1; j < nodes.length; j++) {
            const dx = nodes[j].x - nodes[i].x;
            const dy = nodes[j].y - nodes[i].y;
            const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
            const f = SIM.repulsion / (dist * dist);
            const fx = (dx / dist) * f;
            const fy = (dy / dist) * f;
            nodes[i].ax -= fx; nodes[i].ay -= fy;
            nodes[j].ax += fx; nodes[j].ay += fy;
        }
    }

    // 2. Attraction (edges)
    for (const e of edges) {
        const s = e.source, t = e.target;
        const dx = t.x - s.x, dy = t.y - s.y;
        const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
        const f = SIM.attraction * (dist - SIM.edgeLength);
        const fx = (dx / dist) * f;
        const fy = (dy / dist) * f;
        s.ax += fx; s.ay += fy;
        t.ax -= fx; t.ay -= fy;
    }

    // 3. Center gravity + 4. Integration
    const cx = canvas.width / 2, cy = canvas.height / 2;
    for (const n of nodes) {
        if (n.pinned) continue; // dragged nodes don't move
        n.ax += (cx - n.x) * SIM.centerGravity;
        n.ay += (cy - n.y) * SIM.centerGravity;
        n.vx = clamp((n.vx + n.ax) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
        n.vy = clamp((n.vy + n.ay) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
        n.x += n.vx;
        n.y += n.vy;
    }
}

The simulation runs in requestAnimationFrame. Once total kinetic energy drops below a threshold, the loop switches to on-demand rendering (redraw only on interaction) to save battery.

Canvas Rendering

function render(ctx, nodes, edges, state) {
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

    // Apply zoom/pan transform
    ctx.save();
    ctx.translate(state.panX, state.panY);
    ctx.scale(state.zoom, state.zoom);

    // Edges
    for (const e of edges) {
        ctx.beginPath();
        ctx.moveTo(e.source.x, e.source.y);
        ctx.lineTo(e.target.x, e.target.y);
        ctx.strokeStyle = e.is_transclusion
            ? 'rgba(var(--accent-rgb), 0.6)'   // dashed for embeds
            : 'rgba(150, 150, 150, 0.4)';
        if (e.is_transclusion) ctx.setLineDash([4, 4]);
        else ctx.setLineDash([]);
        ctx.lineWidth = e.highlighted ? 2 : 1;
        ctx.stroke();
    }

    // Nodes
    for (const n of nodes) {
        const r = baseRadius + Math.sqrt(n.link_count) * 2; // size by connectivity
        ctx.beginPath();
        ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
        ctx.fillStyle = _folderColor(n.folder_path);         // color by folder
        if (n.hovered || n.selected) {
            ctx.fillStyle = 'var(--accent)';
        }
        ctx.fill();
        ctx.strokeStyle = n.selected ? '#fff' : 'rgba(0,0,0,0.2)';
        ctx.lineWidth = n.selected ? 2 : 1;
        ctx.stroke();

        // Labels (only at sufficient zoom)
        if (state.zoom > 0.6) {
            ctx.fillStyle = 'var(--text-primary)';
            ctx.font = `${11 / state.zoom}px var(--font-family)`;
            ctx.textAlign = 'center';
            ctx.fillText(n.title, n.x, n.y + r + 12);
        }
    }

    ctx.restore();
}

Interaction

  • Pan: Mouse drag on background / touch drag
  • Zoom: Scroll wheel / pinch — clamped to [0.2, 3.0]
  • Drag node: Click-and-drag pins the node; release unpins
  • Hover: Highlight node + its direct edges/neighbors (dim others)
  • Click node: Open that note in the editor (openNoteEditor(node.id) — graph stays open in background)
  • Right-click / long-press: Context menu with "Open note", "Show backlinks", "Focus neighborhood"
  • Tag coloring: Tag pills in the legend toggle folder-based or tag-based coloring mode

Hit detection uses simple distance-from-center checks (cheaper than path-based detection and sufficient for circular nodes).

Graph UI Placement

The graph opens as a full side-panel view (replacing the notes list), toggled by a new "Graph" button in the notes panel toolbar:

<div id="notesGraphView" class="notes-graph-view" style="display:none">
    <div class="notes-graph-toolbar">
        <button onclick="showNotesList()" title="Back to list">← List</button>
        <span class="notes-graph-stats">
            <span id="graphNodeCount">0</span> notes ·
            <span id="graphEdgeCount">0</span> links
        </span>
        <button onclick="_graphResetZoom()" title="Reset view"></button>
    </div>
    <canvas id="noteGraphCanvas"></canvas>
</div>

The Canvas fills the panel and resizes with it (ResizeObserver). The graph fetches data from GET /api/v1/notes/graph on open and caches it for the session (invalidated on note save/delete).

Orphan Detection

Nodes with zero inbound and zero outbound links are rendered as smaller, muted circles at the periphery. The graph toolbar includes a filter toggle: "Show orphans" (default on) to declutter dense graphs.


Note-from-Selection (Chat → Notes Bridge)

UX Flow

A new "Save to Note" button is added to the message action bar alongside the existing Copy/Edit/Regen buttons:

// In ui-core.js, message template
const saveNoteBtn = `<button class="msg-action-btn"
    onclick="saveMessageToNote(${index})" title="Save to note">Note</button>`;

When clicked:

  1. Full message: If no text is selected within the message, the entire message content is used.
  2. Selection: If the user has text selected within the message, only the selected text is captured (via window.getSelection()).

A small modal/popover appears with:

  • Title field (pre-filled: first line of content, truncated to 60 chars)
  • Folder dropdown (existing folders + "new folder" option)
  • Tags input
  • Append to existing note toggle — when enabled, shows a note picker (reuses searchNoteTitles autocomplete)
  • Create / Append button
async function saveMessageToNote(msgIndex) {
    const chat = App.chats.find(c => c.id === App.currentChatId);
    const msg = chat?.messages[msgIndex];
    if (!msg) return;

    // Check for text selection within the message element
    const sel = window.getSelection();
    let content = msg.content;
    const msgEl = document.querySelector(
        `.message[data-msg-id="${msg.id}"] .msg-text`);
    if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) {
        content = sel.toString().trim() || msg.content;
    }

    _showSaveToNoteModal({
        content,
        sourceChannelId: App.currentChatId,
        sourceMessageId: msg.id,
        defaultTitle: _extractTitle(content),
    });
}

function _extractTitle(content) {
    const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
    return firstLine.slice(0, 60) || 'Chat excerpt';
}

Backend: Provenance Tracking

The source_channel_id field already exists on the Note model. We add source_message_id for finer provenance:

-- Migration (Postgres)
ALTER TABLE notes ADD COLUMN IF NOT EXISTS source_message_id UUID;
CREATE INDEX IF NOT EXISTS idx_notes_source_msg ON notes(source_message_id)
    WHERE source_message_id IS NOT NULL;

-- Migration (SQLite)
ALTER TABLE notes ADD COLUMN source_message_id TEXT;

The create note request body gains optional source_message_id:

type createNoteRequest struct {
    Title           string   `json:"title" binding:"required"`
    Content         string   `json:"content" binding:"required"`
    FolderPath      string   `json:"folder_path"`
    Tags            []string `json:"tags"`
    SourceChannelID string   `json:"source_channel_id"`
    SourceMessageID string   `json:"source_message_id"`
}

This enables future "jump to source" — from a note, navigate back to the exact chat message it was extracted from.

Append Mode

When the user chooses "Append to existing note", the frontend calls the existing PUT /api/v1/notes/:id with mode: "append":

await API.updateNote(targetNoteId, {
    content: '\n\n---\n\n' + content,
    mode: 'append',
});

The separator (---) visually distinguishes appended excerpts. The append mode already exists in the backend.


Daily Notes

UX

A "Today" button in the notes panel toolbar creates or opens today's daily note:

<button id="notesTodayBtn" onclick="openDailyNote()" title="Today's note">
    📅 Today
</button>
async function openDailyNote() {
    const today = new Date().toISOString().slice(0, 10); // "2026-02-28"
    const title = `Daily — ${today}`;
    const folder = '/daily';

    // Try to find existing daily note
    const resp = await API.searchNoteTitles(title, 1);
    const existing = (resp.data || []).find(
        n => n.title.toLowerCase() === title.toLowerCase()
    );

    if (existing) {
        await openNoteEditor(existing.id);
    } else {
        // Create with template
        const content = _dailyTemplate(today);
        const created = await API.createNote(title, content, folder, ['daily']);
        await openNoteEditor(created.id);
    }
}

function _dailyTemplate(dateStr) {
    return `# ${dateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
}

Daily notes live in the /daily folder by convention. The template is intentionally minimal — users customize by editing. A future enhancement could allow configurable daily note templates via settings.

Linking Daily Notes

Daily notes are regular notes and participate fully in [[wikilinks]]. A user can link from any note to a daily note: [[Daily — 2026-02-28]] and vice versa.

Previous/next day navigation buttons appear in the editor toolbar when viewing a daily note (detected by folder = /daily and title pattern):

function _isDailyNote(note) {
    return note.folder_path === '/daily' &&
           /^Daily — \d{4}-\d{2}-\d{2}$/.test(note.title);
}

Access Control

Links respect existing note ownership. A user can only:

  • Create links to notes they own (their own notes)
  • See backlinks from notes they own
  • Autocomplete searches their own notes
  • View their own graph

Team-scoped notes (via team_id on the notes table) follow the same grant model as other team resources. When team notes ship (potentially v0.23.0 channel-scoped notes), the link resolution query adds AND (user_id = $1 OR team_id IN (...)).

For v0.17.3, the scope is personal notes only — which is the current notes model.


Store Layer

NoteLinkStore Interface

type NoteLinkStore interface {
    // ReplaceLinks deletes existing links for sourceNoteID and inserts new ones.
    // Each link has TargetTitle (always set) and TargetNoteID (nullable, resolved).
    ReplaceLinks(ctx context.Context, sourceNoteID string, links []NoteLink) error

    // ResolveByTitle sets target_note_id on dangling links matching the title.
    ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error

    // Backlinks returns notes that link to the given note.
    Backlinks(ctx context.Context, noteID string) ([]NoteLinkResult, error)

    // Graph returns all nodes and edges for a user's note graph.
    Graph(ctx context.Context, userID string) (*NoteGraph, error)
}

type NoteLink struct {
    TargetNoteID    *string // nil for unresolved
    TargetTitle     string
    DisplayText     string
    IsTransclusion  bool
}

type NoteLinkResult struct {
    SourceNoteID string
    Title        string
    FolderPath   string
    UpdatedAt    time.Time
    DisplayText  string
}

type NoteGraph struct {
    Nodes      []NoteGraphNode
    Edges      []NoteGraphEdge
    Unresolved []NoteGraphDangling
}

type NoteGraphNode struct {
    ID         string    `json:"id"`
    Title      string    `json:"title"`
    FolderPath string    `json:"folder_path"`
    Tags       []string  `json:"tags"`
    UpdatedAt  time.Time `json:"updated_at"`
    LinkCount  int       `json:"link_count"`
}

type NoteGraphEdge struct {
    Source         string `json:"source"`
    Target         string `json:"target"`
    Title          string `json:"title"`
    IsTransclusion bool   `json:"is_transclusion"`
}

type NoteGraphDangling struct {
    Source string `json:"source"`
    Title  string `json:"title"`
}

Implementations in both store/postgres/note_link.go and store/sqlite/note_link.go.

// ExtractWikilinks parses [[Title]], [[Title|Display]], and ![[Title]] from markdown.
func ExtractWikilinks(content string) []NoteLink {
    re := regexp.MustCompile(`(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]`)
    matches := re.FindAllStringSubmatch(content, -1)
    seen := make(map[string]bool)
    var links []NoteLink
    for _, m := range matches {
        title := strings.TrimSpace(m[2])
        key := strings.ToLower(title)
        if seen[key] { continue }
        seen[key] = true
        link := NoteLink{
            TargetTitle:    title,
            IsTransclusion: m[1] == "!",
        }
        if len(m) > 3 && m[3] != "" {
            link.DisplayText = strings.TrimSpace(m[3])
        }
        links = append(links, link)
    }
    return links
}

This lives in a shared notes/ package (or in the handler) — it's pure string parsing with no DB dependency.


Migration

Migration file (both Postgres and SQLite):

-- note_links table
CREATE TABLE IF NOT EXISTS note_links ( ... );
CREATE INDEX ...;

-- source_message_id on notes (for note-from-selection provenance)
ALTER TABLE notes ADD COLUMN IF NOT EXISTS source_message_id UUID;
-- (SQLite: ALTER TABLE notes ADD COLUMN source_message_id TEXT;)

No backfill needed — existing notes have no [[links]] in their content, so the table starts empty. Links populate organically as users edit notes with the new editor.


New Frontend Files

src/js/
  notes.js              # MODIFIED — editor swap, backlinks, daily notes,
                        #   save-to-note modal, graph view toggle
  note-graph.js         # NEW — Canvas graph renderer, force simulation,
                        #   interaction handling, graph data fetching

src/editor/
  note-editor.mjs       # NEW — CM.noteEditor() factory
  wikilink.mjs          # NEW — CM6 extension (parse, decorate, autocomplete)
  index.mjs             # MODIFIED — exports noteEditor

src/css/
  styles.css            # MODIFIED — graph, transclusion, backlink, daily note styles

note-graph.js is loaded as a regular <script> (like other modules) but initializes lazily — it defines functions but doesn't execute until the graph view is opened.


Implementation Checklist

  • Migration: note_links table (Postgres + SQLite)
  • Migration: source_message_id column on notes (Postgres + SQLite)
  • NoteLinkStore interface + Postgres/SQLite implementations
  • ExtractWikilinks() helper with tests
  • Link extraction on note create/update in handlers
  • Dangling link resolution on note create
  • GET /api/v1/notes/:id/backlinks endpoint
  • GET /api/v1/notes/search-titles?q= endpoint
  • GET /api/v1/notes/graph endpoint
  • source_message_id support in create handler
  • API client additions in api.js

Phase 2: Frontend (CM6 note editor)

  • src/editor/note-editor.mjsCM.noteEditor() factory
  • src/editor/wikilink.mjs — CM6 extension (parse, decorate, autocomplete)
  • Rebuild CM6 bundle (add new modules to index.mjs)
  • Replace <textarea> with CM6 note editor in notes.js
  • saveNote() reads from CM6 .getValue()
  • Graceful fallback if CM6 unavailable
  • Backlinks panel UI below editor
  • [[link]] rendering in note read mode (marked.js post-render pass)
  • ![[transclusion]] rendering in read mode with recursion guard

Phase 3: Graph view

  • src/js/note-graph.js — force simulation engine
  • Canvas rendering (nodes, edges, labels)
  • Interaction: pan, zoom (wheel + pinch), drag nodes
  • Interaction: hover highlight, click to open note
  • Node sizing by link count, coloring by folder/tag
  • Transclusion edges rendered as dashed lines
  • Orphan detection and filter toggle
  • Unresolved links as ghost nodes (optional display)
  • Graph toolbar (back to list, stats, reset zoom)
  • ResizeObserver for panel resize tracking
  • Energy-based simulation pause (stop rAF when converged)

Phase 4: Note-from-selection + daily notes

  • "Note" button in message action bar (ui-core.js)
  • Save-to-note modal (title, folder, tags, append toggle)
  • Selection detection (window.getSelection() within message)
  • Append-to-existing-note flow (note picker + mode: append)
  • "Today" button in notes toolbar
  • openDailyNote() — find-or-create with template
  • Previous/next day navigation for daily notes
  • /daily folder convention

Phase 5: Polish

  • Wikilink chips styled to match app theme (accent color, hover state)
  • Unresolved links styled distinctly (red/dashed, "click to create")
  • Transclusion embeds: border, header bar, expand/collapse
  • Autocomplete dropdown styled (note title + folder path hint)
  • Keyboard navigation in autocomplete (↑/↓, Enter, Esc)
  • Graph: smooth zoom transitions, node label fade at distance
  • Graph: dark/light theme support (_folderColor palette)
  • Mobile/touch: link taps, graph pinch-zoom, note-from-selection
  • Update ARCHITECTURE.md
  • Update ROADMAP.md (move graph/transclusion/daily out of Future)

Performance Considerations

Graph rendering: The O(n²) repulsion loop is acceptable for up to ~500 nodes at 60fps on modern hardware. If users report performance issues with large collections:

  • Add Barnes-Hut quadtree spatial partitioning (~100 lines, reduces to O(n log n))
  • WebWorker the simulation (compute positions off-main-thread, post node coordinates back for rendering)
  • Neither is needed for initial release

Transclusion API calls: Each ![[embed]] triggers a getNote() fetch. For a note with many embeds, this could cause a burst of requests. Mitigated by:

  • Client-side cache of fetched notes (already partially in place via _currentNote)
  • A batch endpoint could be added later (POST /api/v1/notes/batch with a list of IDs) but is premature for v0.17.3

Graph data caching: The graph payload is cached client-side for the session and invalidated on note save/delete. Avoids re-fetching on every graph open.


Future (not in v0.17.3)

  • Link suggestions: "notes that might be related" via embedding similarity (reuse KB infrastructure)
  • Tag linking: #tag syntax as an alternative navigation path
  • Team note links: cross-user linking when team/channel-scoped notes ship
  • Configurable daily note templates: user-defined template in settings
  • Graph clustering: visual grouping by folder with hull/boundary rendering
  • Graph search: highlight matching nodes, zoom to fit results
  • Graph export: SVG or PNG snapshot of current graph view