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 11:58:27 +00:00

14 KiB

DESIGN: Notes Rich Text Editor + Obsidian-Style Linking

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, and a backlinks system.

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.


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

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
    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,
    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.

When a note is saved, the backend:

  1. Parses content for [[...]] patterns (simple regex: \[\[([^\]|]+)(?:\|([^\]]+))?\]\])
  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.


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.

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.


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.


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

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)
}

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

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

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

// ExtractWikilinks parses [[Title]] and [[Title|Display]] from markdown content.
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[1])
        if seen[strings.ToLower(title)] { continue }
        seen[strings.ToLower(title)] = true
        link := NoteLink{TargetTitle: title}
        if len(m) > 2 && m[2] != "" {
            link.DisplayText = strings.TrimSpace(m[2])
        }
        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 002 (both Postgres and SQLite):

-- 002_note_links.sql
CREATE TABLE IF NOT EXISTS note_links ( ... );
CREATE INDEX ...;

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.


Implementation Checklist

  • Migration 002: note_links table (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
  • 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 list preview (marked.js extension)

Phase 3: Polish

  • Wikilink chips styled to match app theme (accent color, hover state)
  • Unresolved links styled distinctly (red/dashed, "click to create")
  • Autocomplete dropdown styled (note title + folder path hint)
  • Keyboard navigation in autocomplete (↑/↓, Enter to select, Esc to dismiss)
  • Mobile/touch testing for link taps
  • Update ARCHITECTURE.md

Future (not in v0.17.3)

  • Graph view: visual node graph of note connections (d3 force layout)
  • Orphan detection: notes with no inbound or outbound links
  • Link suggestions: "notes that might be related" via embedding similarity (reuse KB infrastructure)
  • Transclusion: ![[Note Title]] embeds the target note's content inline
  • Tag linking: #tag syntax as an alternative navigation path
  • Team note links: cross-user linking when team/channel-scoped notes ship