1062 lines
35 KiB
Markdown
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
|