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 (CM6Decoration.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)
Wikilink Syntax
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:
- Exact title match (case-insensitive)
- Fuzzy title match (for autocomplete, not for rendering)
- 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
note_links Table
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_idis nullable — an unresolved link (target note doesn't exist yet) stores the title but hasNULLfor 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_titleis always stored for human readability and re-resolution after renames.
Link Extraction (on save)
When a note is saved, the backend:
- Parses content for
[[...]]patterns (simple regex:\[\[([^\]|]+)(?:\|([^\]]+))?\]\]) - Resolves each title to a note ID:
SELECT id FROM notes WHERE LOWER(title) = LOWER($1) AND user_id = $2 - Deletes existing links for this source note:
DELETE FROM note_links WHERE source_note_id = $1 - 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.
Backlinks Query
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;
Dangling Link Resolution
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).
Backlinks Panel
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.
Link Extraction Helper
// 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
Phase 1: Backend (links infrastructure)
- Migration 002:
note_linkstable (Postgres + SQLite) NoteLinkStoreinterface + Postgres/SQLite implementationsExtractWikilinks()helper with tests- Link extraction on note create/update in handlers
- Dangling link resolution on note create
GET /api/v1/notes/:id/backlinksendpointGET /api/v1/notes/search-titles?q=endpoint- API client additions in
api.js
Phase 2: Frontend (CM6 note editor)
src/editor/note-editor.mjs—CM.noteEditor()factorysrc/editor/wikilink.mjs— CM6 extension (parse, decorate, autocomplete)- Rebuild CM6 bundle (add new modules to
index.mjs) - Replace
<textarea>with CM6 note editor innotes.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:
#tagsyntax as an alternative navigation path - Team note links: cross-user linking when team/channel-scoped notes ship