Changeset 0.17.2 (#77)
This commit is contained in:
451
docs/DESIGN-0.17.3.md
Normal file
451
docs/DESIGN-0.17.3.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user