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

1062 lines
35 KiB
Markdown

# 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.
```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`)
- `![[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)
### 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"
![[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
### `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
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.
### Link Extraction (on save)
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.
### 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.
### 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.
```sql
-- 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.
```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.
**`GET /api/v1/notes/graph`**
Returns the full graph topology for the current user.
```json
{
"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:
```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.
### 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()`:
```javascript
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:
```javascript
// 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.
```javascript
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
```javascript
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:
```html
<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:
```javascript
// 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
```javascript
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:
```sql
-- 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`:
```go
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"`:
```javascript
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:
```html
<button id="notesTodayBtn" onclick="openDailyNote()" title="Today's note">
📅 Today
</button>
```
```javascript
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):
```javascript
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
```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)
// 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`.
### Link Extraction Helper
```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):
```sql
-- 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
### Phase 1: Backend (links infrastructure)
- [ ] 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.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 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