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

452 lines
14 KiB
Markdown

# 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.
```javascript
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)
### 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:
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
### `note_links` Table
Tracks directed links between notes, extracted on save.
```sql
-- 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.
### Link Extraction (on save)
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.
### Backlinks Query
```sql
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:
```sql
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.
```json
{
"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.
```json
{
"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:
```javascript
// 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:
```html
<textarea id="noteEditorContent" rows="12" ...></textarea>
```
To:
```html
<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:
```html
<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:
```javascript
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
```go
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
```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):
```sql
-- 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_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.mjs``CM.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