Changeset 0.17.3 (#78)

This commit is contained in:
2026-02-28 15:20:23 +00:00
parent a008dac488
commit 12e316c234
29 changed files with 3347 additions and 65 deletions

View File

@@ -2,6 +2,92 @@
All notable changes to Chat Switchboard.
## [0.17.3] — 2026-02-28
### Added
- **Wikilink bi-directional linking.** Notes support `[[Title]]` and
`[[Title|display text]]` syntax. Links are extracted on save via regex,
resolved to target note IDs by case-insensitive title match, and stored
in a `note_links` junction table. Dangling links (references to notes
that don't exist yet) are preserved and automatically resolved when a
matching note is created later.
- **Transclusion embeds.** `![[Title]]` syntax renders embedded note content
inline in read mode. Content is fetched asynchronously with a recursion
guard (max depth 1 — nested transclusions render as plain text references).
Transclusion links are visually distinct in both the editor (border-left,
italic) and the graph (dashed edges).
- **Backlinks panel.** Read mode shows a collapsible "Linked mentions" panel
below the note content listing all notes that link to the current note.
Each backlink is clickable to navigate directly. Count badge updates on
every note open.
- **Knowledge graph visualization.** Canvas-based force-directed graph with
no external dependencies (~480 lines). Physics: O(n²) Coulomb repulsion,
Hooke spring attraction on edges, center gravity. Interaction: pan, zoom
(0.154.0x toward cursor), drag nodes, hover highlights node + neighbors.
Click opens note editor. Nodes sized by √(link_count), colored by folder
path (10-color palette). Ghost nodes for unresolved `[[links]]` with
toggle button. Energy-based pause (stops rAF when total kinetic energy
falls below threshold). ResizeObserver for responsive canvas sizing.
- **CM6 note editor.** New `CM.noteEditor()` factory function with live
markdown preview (heading sizes for h1h3, blockquote styling, fenced code
block decorations), `[[wikilink]]` autocomplete triggered by `[[` with
async title search, and clickable wikilink chip rendering. Falls back to
plain `<textarea>` if CM6 bundle is unavailable.
- **Wikilink autocomplete.** Typing `[[` in the note editor triggers an
async completion popup backed by `GET /notes/search-titles?q=`. Results
are fetched via `ILIKE` title match, limited to 8 suggestions. Selecting
a result inserts the full `[[Title]]` syntax.
- **Daily notes.** "Today" button in the notes toolbar creates or opens a
daily note titled `Daily — YYYY-MM-DD` in the `/daily/` folder with a
starter template (Tasks + Notes sections). Idempotent — repeated clicks
navigate to the existing daily note.
- **Save-to-note from chat.** "Note" button in message action bar captures
the full message content (or the current text selection within that message)
into a new note with pre-filled title extracted from the first line.
Provenance is tracked via `source_message_id` column on the notes table,
enabling future jump-to-source navigation.
- **API endpoints.** Three new authenticated endpoints:
- `GET /notes/search-titles?q=&limit=` — lightweight title search for
autocomplete (returns `[{id, title}]`).
- `GET /notes/:id/backlinks` — notes linking to the specified note, with
display text and metadata.
- `GET /notes/graph` — full graph topology: nodes (with inbound/outbound
link counts), resolved edges, and unresolved (dangling) link titles.
- **`note_links` table.** New junction table with `source_note_id`,
`target_note_id` (nullable for dangling links), `target_title`,
`display_text`, `is_transclusion`, and `created_at`. Primary key on
`(source_note_id, target_title)`. Index on `target_note_id` for backlink
queries. Migrations for both Postgres and SQLite.
- **`source_message_id` column.** New nullable UUID column on `notes` table
for chat-to-note provenance tracking.
- **Wikilink extraction package.** Standalone `notelinks/` package with
regex-based parser handling `[[Title]]`, `[[Title|Display]]`,
`![[Title]]`, and `![[Title|Display]]`. Deduplicates by lowercase title,
preserves first occurrence. 10 unit tests covering edge cases.
### Changed
- Notes editor replaces `<textarea>` with CM6 `noteEditor` instance. The
container `#noteEditorContentContainer` is lazily initialized on first
edit; destroyed on panel close to avoid stale state.
- Note create and update handlers now extract wikilinks from content and
call `ReplaceLinks()` (transactional DELETE + batch INSERT). Create also
calls `ResolveByTitle()` to fix dangling links from other notes.
- `showNotesList()` destroys the CM6 editor instance and hides graph view.
- `saveNote()` and `deleteNote()` call `invalidateNoteGraph()` to clear
the cached graph data.
- Read mode renders `[[wikilinks]]` as styled clickable chips via post-
processing of the formatted HTML. Clicking navigates to the linked note
or offers to create it if not found.
- `ARCHITECTURE.md` updated with `notelinks/` package, `note-graph.js`,
`CM.noteEditor()` factory, and expanded notes description covering the
linking model.
- `ROADMAP.md` updated with full v0.17.3 section including all checklist
items.
- CM6 bundle entrypoint (`index.mjs`) exports `noteEditor` alongside
existing `chatInput` and `codeEditor`.
- `theme.mjs` adds `noteEditorTheme` with full-height layout (min 200px,
max 60vh), heading size decorations, and blockquote styling.
## [0.17.2] — 2026-02-28
### Added

View File

@@ -1 +1 @@
0.17.2
0.17.3

View File

@@ -94,8 +94,15 @@ These invariants keep the workflow path open without building it prematurely:
- **Channels**: Don't assume single-owner. If touching channel queries, keep
room for `team_id`, `type` (beyond `direct`), and multi-participant access
patterns alongside `user_id`.
- **Notes**: Currently user-scoped. Future channel-scoped notes (attached to
a conversation, not a personal notebook) need a `channel_id` FK option.
- **Notes**: User-scoped with `[[wikilink]]` bi-directional linking (v0.17.3).
The `note_links` junction table tracks directed edges between notes, with
`target_note_id` nullable for dangling links (unresolved references). Links
are extracted from content on save via regex, resolved by title match, and
re-resolved when new notes are created. The graph endpoint returns all nodes,
edges, and unresolved links for Canvas-based force-directed visualization.
Future channel-scoped notes (attached to a conversation) need a `channel_id`
FK option. `source_message_id` enables jump-to-source provenance from notes
back to the originating chat message.
- **Personas**: Already team-scoped. Don't couple to "user picks from dropdown"
— a workflow stage references a persona programmatically.
- **Tool ExecutionContext**: Already carries `UserID` + `ChannelID`. Will need
@@ -153,9 +160,10 @@ server/
│ ├── presets.go # Persona CRUD (all scopes)
│ ├── apiconfigs.go # User provider config CRUD (BYOK)
│ ├── teams.go # Team management
│ ├── notes.go # Notes CRUD + search
│ ├── notes.go # Notes CRUD + search + graph + backlinks
│ ├── knowledge.go # Knowledge base management
│ └── ...
├── notelinks/ # Wikilink extraction (regex → NoteLink structs)
├── knowledge/ # KB chunking, embedding, search
├── providers/ # LLM provider adapters
│ ├── provider.go # Provider interface
@@ -257,7 +265,8 @@ src/
│ ├── settings-handlers.js # User settings CRUD, command palette
│ ├── tokens.js # Input token counting + context budget
│ ├── attachments.js # File upload, paste-to-file, lightbox
│ ├── notes.js # Notes panel CRUD
│ ├── notes.js # Notes panel CRUD + graph + daily notes
│ ├── note-graph.js # Canvas force-directed graph visualization
│ ├── knowledge.js # Knowledge base UI
│ └── __tests__/ # Node.js test suite (node --test)
├── editor/ # CM6 source (compiled at build time)
@@ -267,7 +276,9 @@ src/
│ ├── index.mjs # Bundle entrypoint (window.CM)
│ ├── chat-input.mjs # Chat input factory (markdown, code blocks)
│ ├── code-editor.mjs # Code editor factory (syntax, keybindings)
── theme.mjs # Switchboard + chat input themes
── note-editor.mjs # Note editor factory (markdown + wikilinks)
│ ├── wikilink.mjs # CM6 wikilink extension (parse, decorate, autocomplete)
│ └── theme.mjs # Switchboard + chat input + note editor themes
└── vendor/ # Vendored libraries (local + CDN fallback)
├── marked/ # Markdown renderer
├── purify/ # DOMPurify (XSS protection)
@@ -279,12 +290,14 @@ src/
### CM6 Integration
The CodeMirror 6 bundle provides two factory functions exposed on `window.CM`:
The CodeMirror 6 bundle provides three factory functions exposed on `window.CM`:
**`CM.chatInput(target, opts)`** — Markdown-mode editor for the chat input area. Features: auto-growing height, Enter=send / Shift+Enter=newline, WYSIWYG fenced code block decorations (visual container with monospace font and accent border), inline code shortcut (Ctrl/Cmd+E), spell check. Always uses standard keybindings.
**`CM.codeEditor(target, opts)`** — Full-featured code editor for the admin extension panel (JSON manifests, JavaScript scripts). Features: line numbers, bracket matching, search/replace, fold gutter, 10 bundled language modes. Supports Vim and Emacs keybindings via user preference (reconfigurable at runtime via compartments).
**`CM.noteEditor(target, opts)`** — Rich markdown editor for the notes panel. Features: heading size rendering (h1h3), blockquote styling, fenced code block decorations, `[[wikilink]]` autocomplete (triggered by `[[`), wikilink chip rendering (clickable, styled by link/transclusion type), search/replace. `onLink` callback for navigating to linked notes; `linkCompleter` async callback for autocomplete results.
Both factories return a clean API: `getValue()`, `setValue()`, `focus()`, `destroy()`. The `ChatInput` abstraction in `chat.js` wraps the CM6 instance with a textarea fallback — all callsites use `ChatInput.getValue()` etc., never raw DOM access.
**Graceful degradation**: Every integration point checks `window.CM` before using CM6. If the bundle fails to load (air-gapped without the build step, CDN issues), the app falls back to native `<textarea>` with zero breakage.

View File

@@ -1,10 +1,11 @@
# DESIGN: Notes Rich Text Editor + Obsidian-Style Linking
# 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, and a backlinks
system.
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).
@@ -24,6 +25,12 @@ 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
@@ -50,6 +57,8 @@ CM.noteEditor(target, {
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)
@@ -61,6 +70,7 @@ 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:
@@ -105,6 +115,7 @@ CREATE TABLE IF NOT EXISTS note_links (
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)
);
@@ -118,6 +129,7 @@ CREATE TABLE IF NOT EXISTS note_links (
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)
);
@@ -135,12 +147,15 @@ CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target_note_id)
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 `[[...]]` patterns (simple regex: `\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`)
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
@@ -173,6 +188,33 @@ 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
@@ -183,7 +225,9 @@ retroactively.
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.
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
@@ -223,6 +267,43 @@ 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
@@ -310,6 +391,414 @@ The existing note list view shows raw markdown. After this release,
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
@@ -318,6 +807,7 @@ converts `[[...]]` to `<a>` tags with `onclick` handlers.
- 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
@@ -344,12 +834,16 @@ type NoteLinkStore interface {
// 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
TargetNoteID *string // nil for unresolved
TargetTitle string
DisplayText string
IsTransclusion bool
}
type NoteLinkResult struct {
@@ -359,6 +853,33 @@ type NoteLinkResult struct {
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
@@ -367,19 +888,23 @@ Implementations in both `store/postgres/note_link.go` and
### Link Extraction Helper
```go
// ExtractWikilinks parses [[Title]] and [[Title|Display]] from markdown content.
// ExtractWikilinks parses [[Title]], [[Title|Display]], and ![[Title]] from markdown.
func ExtractWikilinks(content string) []NoteLink {
re := regexp.MustCompile(`\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]`)
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])
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)
}
@@ -394,12 +919,16 @@ string parsing with no DB dependency.
## Migration
**Migration 002** (both Postgres and SQLite):
**Migration file** (both Postgres and SQLite):
```sql
-- 002_note_links.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,
@@ -408,16 +937,43 @@ 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 002: `note_links` table (Postgres + SQLite)
- [ ] 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)
@@ -428,24 +984,78 @@ with the new editor.
- [ ] `saveNote()` reads from CM6 `.getValue()`
- [ ] Graceful fallback if CM6 unavailable
- [ ] Backlinks panel UI below editor
- [ ] `[[link]]` rendering in note list preview (marked.js extension)
- [ ] `[[link]]` rendering in note read mode (marked.js post-render pass)
- [ ] `![[transclusion]]` rendering in read mode with recursion guard
### Phase 3: Polish
### 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 to select, Esc to dismiss)
- [ ] Mobile/touch testing for link taps
- [ ] 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)
- **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
- **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

View File

@@ -61,12 +61,15 @@ v0.16.0 User Groups v0.17.0 Persona-KB Binding
┌───────┴──────────────┐
│ │
v0.17.1 SQLite Backend ✅ v0.17.2 CodeMirror 6 ✅
v0.17.3 Notes Graph + Wikilinks
(dual DB) (editor bundle, chat
│ input, ext editor)
└───────┬──────────────┘
v0.18.0 Memory (user + persona scopes, review pipeline)
v0.18.1 Side Panel Architecture (independent panels, dual-view)
v0.19.0 Projects / Workspaces + Notifications
v0.20.0 @mention Routing + Multi-model
@@ -337,6 +340,57 @@ Depends on: nothing (frontend-only). Prerequisite for: extension surfaces
---
## v0.17.3 — Notes Graph + Wikilinks
Knowledge graph and bi-directional linking for the notes system. Transforms
notes from flat documents into an interconnected knowledge base with
Obsidian-style `[[wikilinks]]`, backlinks, graph visualization, and
transclusion.
Depends on: CodeMirror 6 (v0.17.2), Notes CRUD (v0.13.x). See
[DESIGN-0.17.3.md](DESIGN-0.17.3.md) for full spec.
**Backend: Note Links**
- [x] `note_links` table (Postgres + SQLite migrations)
- [x] `NoteLinkStore` interface + both implementations
- [x] `ExtractWikilinks()` regex parser + tests
- [x] Link extraction on note create/update
- [x] Dangling link resolution on note create
- [x] `notes.source_message_id` column for chat-to-note provenance
**API Endpoints**
- [x] `GET /notes/:id/backlinks` — notes linking to this note
- [x] `GET /notes/search-titles?q=` — lightweight fuzzy search for autocomplete
- [x] `GET /notes/graph` — full graph topology (nodes + edges + unresolved)
**CM6 Note Editor**
- [x] `note-editor.mjs` factory: markdown live preview (heading sizes, blockquotes, code blocks)
- [x] `wikilink.mjs` CM6 extension: parse, decorate as clickable chips, autocomplete on `[[`
- [x] Textarea → CM6 swap in `notes.js` with graceful fallback
- [x] `noteEditorTheme` in `theme.mjs`
**Graph Visualization**
- [x] Canvas-based force-directed graph (~200 lines, no d3 dependency)
- [x] O(n²) Coulomb repulsion + Hooke spring + center gravity
- [x] Pan, zoom (0.154.0x), drag nodes, hover highlight, click to open
- [x] Node sizing by link_count, folder-based coloring
- [x] Ghost nodes for unresolved `[[links]]` with toggle
- [x] Energy-based pause (stop rAF below KE threshold)
- [x] ResizeObserver for responsive canvas
**Note-from-Selection + Daily Notes**
- [x] "Note" button in message action bar
- [x] Selection-aware capture (`window.getSelection()` within message)
- [x] Provenance tracking (`source_channel_id` + `source_message_id`)
- [x] "Today" button → find-or-create daily note in `/daily/`
**Read Mode**
- [x] `[[link]]` rendered as clickable wikilink chips
- [x] `![[embed]]` transclusion with async content fetch, recursion guard
- [x] Backlinks panel (collapsible, with count badge)
---
## v0.18.0 — Memory (User + Persona Scopes)
Long-term memory across conversations, with scope-aware isolation.
@@ -398,6 +452,37 @@ Depends on: compaction (v0.15.0), knowledge bases (v0.14.0), persona-KB binding
---
## v0.18.1 — Side Panel Architecture
Refactor the side panel system so Notes, HTML/code previews, and future
panels (knowledge base browser, search results) operate independently
rather than sharing a single slot. Currently, opening Notes hides the
preview panel and vice versa — this is confusing when reviewing AI-
generated HTML/documents while also managing notes.
Depends on: Notes graph + wikilinks (v0.17.3). Prerequisite for: live
collaboration (v0.23.0) where multiple panels need simultaneous visibility.
**Panel System**
- [ ] Panel registry: named panels with independent open/close state
- [ ] Dual-view mode: two panels side-by-side (configurable split ratio)
- [ ] Tab-persistent state: each panel remembers scroll position, open note, etc.
- [ ] Panel priority: when space is tight (mobile), most-recently-opened wins
- [ ] Keyboard shortcut: Ctrl/Cmd+\ cycles panels, Ctrl/Cmd+Shift+\ toggles dual
**Preview Panel**
- [ ] Dedicated preview panel (independent of Notes)
- [ ] HTML/document preview with iframe sandbox
- [ ] Code output preview (extension-rendered blocks)
- [ ] Live-updating during streaming responses
**Mobile**
- [ ] Swipe navigation between panels
- [ ] Bottom sheet mode for secondary panel
- [ ] Graceful collapse to single-panel on narrow viewports
---
## v0.19.0 — Projects / Workspaces + Notifications
Organizational containers that group related conversations, KBs, and

View File

@@ -0,0 +1,35 @@
-- v0.17.3: Note Links + Wikilink Infrastructure
--
-- New: note_links table for [[wikilink]] graph edges
-- New: notes.source_message_id for chat-to-note provenance
-- =========================================
-- 1. Note Links
-- =========================================
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,
display_text TEXT,
is_transclusion BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT 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;
COMMENT ON TABLE note_links IS 'Directed links between notes, extracted from [[wikilink]] syntax on save';
COMMENT ON COLUMN note_links.target_note_id IS 'NULL for unresolved links (target note does not exist yet)';
COMMENT ON COLUMN note_links.target_title IS 'Raw [[title]] text — preserved for re-resolution after renames';
COMMENT ON COLUMN note_links.is_transclusion IS 'true for ![[embed]] syntax, false for regular [[link]]';
-- =========================================
-- 2. Source Message Provenance
-- =========================================
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;

View File

@@ -0,0 +1,26 @@
-- v0.17.3: Note Links + Wikilink Infrastructure (SQLite)
--
-- New: note_links table for [[wikilink]] graph edges
-- New: notes.source_message_id for chat-to-note provenance
-- =========================================
-- 1. Note Links
-- =========================================
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);
-- =========================================
-- 2. Source Message Provenance
-- =========================================
ALTER TABLE notes ADD COLUMN source_message_id TEXT;

View File

@@ -9,6 +9,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notelinks"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -20,6 +21,7 @@ type createNoteRequest struct {
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
SourceChannelID string `json:"source_channel_id"`
SourceMessageID string `json:"source_message_id"`
}
type updateNoteRequest struct {
@@ -38,6 +40,7 @@ type noteResponse struct {
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
SourceChannelID *string `json:"source_channel_id,omitempty"`
SourceMessageID *string `json:"source_message_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
@@ -85,6 +88,7 @@ func toNoteResponse(n *models.Note) noteResponse {
FolderPath: n.FolderPath,
Tags: tags,
SourceChannelID: n.SourceChannelID,
SourceMessageID: n.SourceMessageID,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
@@ -132,6 +136,10 @@ func (h *NoteHandler) Create(c *gin.Context) {
if req.SourceChannelID != "" {
sourceChannelID = &req.SourceChannelID
}
var sourceMessageID *string
if req.SourceMessageID != "" {
sourceMessageID = &req.SourceMessageID
}
note := &models.Note{
UserID: userID,
@@ -140,6 +148,7 @@ func (h *NoteHandler) Create(c *gin.Context) {
FolderPath: folder,
Tags: tags,
SourceChannelID: sourceChannelID,
SourceMessageID: sourceMessageID,
}
if err := h.stores.Notes.Create(c.Request.Context(), note); err != nil {
@@ -147,6 +156,14 @@ func (h *NoteHandler) Create(c *gin.Context) {
return
}
// Extract and store wikilinks
h.extractAndStoreLinks(c, note.ID, note.Content, userID)
// Resolve dangling links from other notes that reference this title
if h.stores.NoteLinks != nil {
h.stores.NoteLinks.ResolveByTitle(c.Request.Context(), userID, note.ID, note.Title)
}
c.JSON(http.StatusCreated, toNoteResponse(note))
}
@@ -235,6 +252,11 @@ func (h *NoteHandler) Update(c *gin.Context) {
return
}
// Re-extract links if content changed
if req.Content != nil {
h.extractAndStoreLinks(c, noteID, updated.Content, userID)
}
c.JSON(http.StatusOK, toNoteResponse(updated))
}
@@ -500,3 +522,122 @@ func normalizeFolderPath(p string) string {
}
return p
}
// ── Wikilink Endpoints ──────────────────────
// GET /api/v1/notes/search-titles?q=query&limit=10
func (h *NoteHandler) SearchTitles(c *gin.Context) {
userID := getUserID(c)
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 || limit > 50 {
limit = 10
}
notes, err := h.stores.Notes.SearchTitles(c.Request.Context(), userID, q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
type titleResult struct {
ID string `json:"id"`
Title string `json:"title"`
}
results := make([]titleResult, 0, len(notes))
for _, n := range notes {
results = append(results, titleResult{ID: n.ID, Title: n.Title})
}
c.JSON(http.StatusOK, gin.H{"data": results})
}
// GET /api/v1/notes/:id/backlinks
func (h *NoteHandler) Backlinks(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
// Verify ownership
note, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil || note.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
if h.stores.NoteLinks == nil {
c.JSON(http.StatusOK, gin.H{"data": []interface{}{}})
return
}
results, err := h.stores.NoteLinks.Backlinks(c.Request.Context(), noteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load backlinks"})
return
}
if results == nil {
results = []models.NoteLinkResult{}
}
c.JSON(http.StatusOK, gin.H{"data": results})
}
// GET /api/v1/notes/graph
func (h *NoteHandler) Graph(c *gin.Context) {
userID := getUserID(c)
if h.stores.NoteLinks == nil {
c.JSON(http.StatusOK, models.NoteGraph{
Nodes: []models.NoteGraphNode{},
Edges: []models.NoteGraphEdge{},
Unresolved: []models.NoteGraphDangling{},
})
return
}
graph, err := h.stores.NoteLinks.Graph(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load graph"})
return
}
c.JSON(http.StatusOK, graph)
}
// ── Link Extraction Helper ──────────────────
// extractAndStoreLinks parses [[wikilinks]] from content and stores them.
func (h *NoteHandler) extractAndStoreLinks(c *gin.Context, noteID, content, userID string) {
if h.stores.NoteLinks == nil {
return
}
links := notelinks.ExtractWikilinks(content)
if len(links) == 0 {
// Clear any existing links
h.stores.NoteLinks.ReplaceLinks(c.Request.Context(), noteID, nil)
return
}
// Resolve titles to note IDs
ctx := c.Request.Context()
for i, link := range links {
notes, err := h.stores.Notes.SearchTitles(ctx, userID, link.TargetTitle, 1)
if err == nil {
for _, n := range notes {
if strings.EqualFold(n.Title, link.TargetTitle) {
id := n.ID
links[i].TargetNoteID = &id
break
}
}
}
}
h.stores.NoteLinks.ReplaceLinks(ctx, noteID, links)
}

View File

@@ -315,11 +315,14 @@ func main() {
protected.GET("/notes", notes.List)
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/search-titles", notes.SearchTitles)
protected.GET("/notes/folders", notes.ListFolders)
protected.GET("/notes/graph", notes.Graph)
protected.POST("/notes/bulk-delete", notes.BulkDelete)
protected.GET("/notes/:id", notes.Get)
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
protected.GET("/notes/:id/backlinks", notes.Backlinks)
// Attachments (file upload/download)
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)

View File

@@ -389,9 +389,58 @@ type Note struct {
Tags []string `json:"tags,omitempty" db:"tags"`
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
SourceChannelID *string `json:"source_channel_id,omitempty" db:"source_channel_id"`
SourceMessageID *string `json:"source_message_id,omitempty" db:"source_message_id"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
}
// NoteLink represents a directed link extracted from [[wikilink]] syntax.
type NoteLink struct {
TargetNoteID *string `json:"target_note_id,omitempty"`
TargetTitle string `json:"target_title"`
DisplayText string `json:"display_text,omitempty"`
IsTransclusion bool `json:"is_transclusion"`
}
// NoteLinkResult represents a backlink — a note that links to a given note.
type NoteLinkResult struct {
SourceNoteID string `json:"id"`
Title string `json:"title"`
FolderPath string `json:"folder_path"`
UpdatedAt time.Time `json:"updated_at"`
DisplayText string `json:"display_text,omitempty"`
}
// NoteGraphNode is a lightweight note representation for graph display.
type NoteGraphNode struct {
ID string `json:"id"`
Title string `json:"title"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
UpdatedAt string `json:"updated_at"`
LinkCount int `json:"link_count"`
}
// NoteGraphEdge is a resolved link between two notes.
type NoteGraphEdge struct {
Source string `json:"source"`
Target string `json:"target"`
Title string `json:"title"`
IsTransclusion bool `json:"is_transclusion"`
}
// NoteGraphDangling is an unresolved [[link]] reference.
type NoteGraphDangling struct {
Source string `json:"source"`
Title string `json:"title"`
}
// NoteGraph is the full graph topology for a user's notes.
type NoteGraph struct {
Nodes []NoteGraphNode `json:"nodes"`
Edges []NoteGraphEdge `json:"edges"`
Unresolved []NoteGraphDangling `json:"unresolved"`
}
// =========================================
// ATTACHMENTS
// =========================================

View File

@@ -0,0 +1,50 @@
package notelinks
import (
"regexp"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// wikiRe matches [[Title]], [[Title|Display]], ![[Title]], and ![[Title|Display]].
// Title first char rejects [ and ] to avoid matching inside [[[triple]]].
var wikiRe = regexp.MustCompile(`(!?)\[\[([^\[\]|][^\]|]*?)(?:\|([^\]]+?))?\]\]`)
// ExtractWikilinks parses [[Title]], [[Title|Display]], and ![[Title]] from markdown content.
// Returns deduplicated links preserving first occurrence.
// Ignores matches preceded by [ (e.g. [[[triple]]]) since Go regexp lacks lookbehind.
func ExtractWikilinks(content string) []models.NoteLink {
indices := wikiRe.FindAllStringSubmatchIndex(content, -1)
seen := make(map[string]bool)
var links []models.NoteLink
for _, idx := range indices {
matchStart := idx[0] // start of full match (including optional !)
// Skip if preceded by [ — rejects [[[triple]]] patterns
if matchStart > 0 && content[matchStart-1] == '[' {
continue
}
// Extract submatches by index pairs: idx[2*n], idx[2*n+1]
title := strings.TrimSpace(content[idx[4]:idx[5]]) // group 2: title
key := strings.ToLower(title)
if key == "" || seen[key] {
continue
}
seen[key] = true
link := models.NoteLink{
TargetTitle: title,
IsTransclusion: content[idx[2]:idx[3]] == "!", // group 1
}
// group 3: display text (optional — idx[6:7] may be -1)
if idx[6] >= 0 && idx[7] >= 0 {
link.DisplayText = strings.TrimSpace(content[idx[6]:idx[7]])
}
links = append(links, link)
}
return links
}

View File

@@ -0,0 +1,122 @@
package notelinks
import (
"testing"
)
func TestExtractWikilinks(t *testing.T) {
tests := []struct {
name string
content string
want int
checks func(t *testing.T, links []struct{ title, display string; transclusion bool })
}{
{
name: "no links",
content: "Just some plain text with [single brackets]",
want: 0,
},
{
name: "single link",
content: "See [[Project Notes]] for details",
want: 1,
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
if links[0].title != "Project Notes" {
t.Errorf("title = %q, want %q", links[0].title, "Project Notes")
}
if links[0].transclusion {
t.Error("should not be transclusion")
}
},
},
{
name: "link with display text",
content: "Check the [[Architecture Doc|arch doc]] here",
want: 1,
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
if links[0].title != "Architecture Doc" {
t.Errorf("title = %q", links[0].title)
}
if links[0].display != "arch doc" {
t.Errorf("display = %q", links[0].display)
}
},
},
{
name: "transclusion",
content: "Embed this: ![[Meeting Notes]]",
want: 1,
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
if links[0].title != "Meeting Notes" {
t.Errorf("title = %q", links[0].title)
}
if !links[0].transclusion {
t.Error("should be transclusion")
}
},
},
{
name: "multiple links deduplicated",
content: "See [[Alpha]] and [[Beta]] and [[Alpha]] again",
want: 2,
},
{
name: "case-insensitive dedup",
content: "See [[Project]] and [[project]]",
want: 1,
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
if links[0].title != "Project" {
t.Errorf("title = %q, want first occurrence", links[0].title)
}
},
},
{
name: "mixed links and transclusions",
content: "Link to [[Alpha]], embed ![[Beta|b]], and [[Gamma]]",
want: 3,
},
{
name: "whitespace trimmed",
content: "See [[ Spaced Title ]] here",
want: 1,
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
if links[0].title != "Spaced Title" {
t.Errorf("title = %q, want trimmed", links[0].title)
}
},
},
{
name: "nested brackets ignored",
content: "Not a link: [[[triple]]] but [[Valid]] is",
want: 1,
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
if links[0].title != "Valid" {
t.Errorf("title = %q, want Valid", links[0].title)
}
},
},
{
name: "empty brackets ignored",
content: "Empty [[]] should not match",
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
links := ExtractWikilinks(tt.content)
if len(links) != tt.want {
t.Fatalf("got %d links, want %d", len(links), tt.want)
}
if tt.checks != nil {
simplified := make([]struct{ title, display string; transclusion bool }, len(links))
for i, l := range links {
simplified[i] = struct{ title, display string; transclusion bool }{
title: l.TargetTitle, display: l.DisplayText, transclusion: l.IsTransclusion,
}
}
tt.checks(t, simplified)
}
})
}
}

View File

@@ -29,6 +29,7 @@ type Stores struct {
Messages MessageStore
Audit AuditStore
Notes NoteStore
NoteLinks NoteLinkStore
GlobalConfig GlobalConfigStore
Usage UsageStore
Pricing PricingStore
@@ -269,6 +270,7 @@ type NoteStore interface {
Delete(ctx context.Context, id string) error
ListForUser(ctx context.Context, userID string, opts NoteListOptions) ([]models.Note, int, error)
Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Note, int, error)
SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error)
BulkDelete(ctx context.Context, ids []string, userID string) (int, error)
}
@@ -279,6 +281,25 @@ type NoteListOptions struct {
TeamID string
}
// =========================================
// NOTE LINK STORE
// =========================================
// NoteLinkStore manages wikilink edges between notes.
type NoteLinkStore interface {
// ReplaceLinks deletes existing links for sourceNoteID and inserts new ones.
ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error
// ResolveByTitle sets target_note_id on dangling links matching the title for a user.
ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error
// Backlinks returns notes that link to the given note.
Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error)
// Graph returns all nodes and edges for a user's note graph.
Graph(ctx context.Context, userID string) (*models.NoteGraph, error)
}
// =========================================
// GLOBAL CONFIG STORE
// =========================================

View File

@@ -19,31 +19,33 @@ func NewNoteStore() *NoteStore { return &NoteStore{} }
func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
return DB.QueryRowContext(ctx, `
INSERT INTO notes (user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
INSERT INTO notes (user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING id, created_at, updated_at`,
n.UserID, n.Title, n.Content, n.FolderPath,
pq.Array(n.Tags), ToJSON(n.Metadata),
models.NullString(n.SourceChannelID), models.NullString(n.TeamID),
models.NullString(n.SourceChannelID), models.NullString(n.SourceMessageID),
models.NullString(n.TeamID),
).Scan(&n.ID, &n.CreatedAt, &n.UpdatedAt)
}
func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) {
var n models.Note
var sourceChannelID, teamID sql.NullString
var sourceChannelID, sourceMessageID, teamID sql.NullString
var metadataJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, user_id, title, content, folder_path, tags, metadata,
source_channel_id, team_id, created_at, updated_at
source_channel_id, source_message_id, team_id, created_at, updated_at
FROM notes WHERE id = $1`, id).Scan(
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
pq.Array(&n.Tags), &metadataJSON,
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt,
&sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt,
)
if err != nil {
return nil, err
}
n.SourceChannelID = NullableStringPtr(sourceChannelID)
n.SourceMessageID = NullableStringPtr(sourceMessageID)
n.TeamID = NullableStringPtr(teamID)
json.Unmarshal(metadataJSON, &n.Metadata)
return &n, nil
@@ -77,7 +79,7 @@ func (s *NoteStore) Delete(ctx context.Context, id string) error {
func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.NoteListOptions) ([]models.Note, int, error) {
b := NewSelect(
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at",
"notes",
).Where("user_id = ?", userID)
@@ -111,15 +113,16 @@ func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.N
var result []models.Note
for rows.Next() {
var n models.Note
var sourceChannelID, teamID sql.NullString
var sourceChannelID, sourceMessageID, teamID sql.NullString
var metadataJSON []byte
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
pq.Array(&n.Tags), &metadataJSON,
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt)
&sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt)
if err != nil {
return nil, 0, err
}
n.SourceChannelID = NullableStringPtr(sourceChannelID)
n.SourceMessageID = NullableStringPtr(sourceMessageID)
n.TeamID = NullableStringPtr(teamID)
json.Unmarshal(metadataJSON, &n.Metadata)
result = append(result, n)
@@ -131,7 +134,7 @@ func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store
tsQuery := strings.Join(strings.Fields(query), " & ")
b := NewSelect(
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at",
"notes",
).Where("user_id = ?", userID).Where("search_vector @@ to_tsquery('english', ?)", tsQuery)
b.OrderBy("ts_rank(search_vector, to_tsquery('english', '"+tsQuery+"'))", "DESC")
@@ -152,15 +155,16 @@ func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store
var result []models.Note
for rows.Next() {
var n models.Note
var sourceChannelID, teamID sql.NullString
var sourceChannelID, sourceMessageID, teamID sql.NullString
var metadataJSON []byte
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
pq.Array(&n.Tags), &metadataJSON,
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt)
&sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt)
if err != nil {
return nil, 0, err
}
n.SourceChannelID = NullableStringPtr(sourceChannelID)
n.SourceMessageID = NullableStringPtr(sourceMessageID)
n.TeamID = NullableStringPtr(teamID)
json.Unmarshal(metadataJSON, &n.Metadata)
result = append(result, n)
@@ -189,3 +193,29 @@ func (s *NoteStore) BulkDelete(ctx context.Context, ids []string, userID string)
n, _ := result.RowsAffected()
return int(n), nil
}
func (s *NoteStore) SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error) {
if limit <= 0 || limit > 50 {
limit = 10
}
rows, err := DB.QueryContext(ctx, `
SELECT id, title, folder_path
FROM notes
WHERE user_id = $1 AND title ILIKE '%' || $2 || '%'
ORDER BY updated_at DESC
LIMIT $3`, userID, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var results []models.Note
for rows.Next() {
var n models.Note
if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath); err != nil {
return nil, err
}
results = append(results, n)
}
return results, rows.Err()
}

View File

@@ -0,0 +1,179 @@
package postgres
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
"github.com/lib/pq"
)
type NoteLinkStore struct{}
func NewNoteLinkStore() *NoteLinkStore { return &NoteLinkStore{} }
func (s *NoteLinkStore) ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error {
tx, err := DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Delete existing links for this source note
if _, err := tx.ExecContext(ctx,
"DELETE FROM note_links WHERE source_note_id = $1", sourceNoteID); err != nil {
return err
}
// Insert new links
if len(links) > 0 {
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO note_links (source_note_id, target_note_id, target_title, display_text, is_transclusion)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (source_note_id, target_title) DO NOTHING`)
if err != nil {
return err
}
defer stmt.Close()
for _, l := range links {
var targetID interface{}
if l.TargetNoteID != nil {
targetID = *l.TargetNoteID
}
if _, err := stmt.ExecContext(ctx,
sourceNoteID, targetID, l.TargetTitle, l.DisplayText, l.IsTransclusion,
); err != nil {
return err
}
}
}
return tx.Commit()
}
func (s *NoteLinkStore) ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error {
_, err := DB.ExecContext(ctx, `
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)`,
targetNoteID, title, userID)
return err
}
func (s *NoteLinkStore) Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error) {
rows, err := DB.QueryContext(ctx, `
SELECT n.id, n.title, n.folder_path, n.updated_at, COALESCE(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`, noteID)
if err != nil {
return nil, err
}
defer rows.Close()
var results []models.NoteLinkResult
for rows.Next() {
var r models.NoteLinkResult
if err := rows.Scan(&r.SourceNoteID, &r.Title, &r.FolderPath,
&r.UpdatedAt, &r.DisplayText); err != nil {
return nil, err
}
results = append(results, r)
}
return results, rows.Err()
}
func (s *NoteLinkStore) Graph(ctx context.Context, userID string) (*models.NoteGraph, error) {
graph := &models.NoteGraph{
Nodes: make([]models.NoteGraphNode, 0),
Edges: make([]models.NoteGraphEdge, 0),
Unresolved: make([]models.NoteGraphDangling, 0),
}
// Nodes: all user's notes with link counts
nodeRows, err := DB.QueryContext(ctx, `
SELECT n.id, n.title, n.folder_path, n.tags, n.updated_at::text,
COALESCE(outbound.cnt, 0) + COALESCE(inbound.cnt, 0) AS link_count
FROM notes n
LEFT JOIN (
SELECT source_note_id, COUNT(*) AS cnt FROM note_links
WHERE target_note_id IS NOT NULL GROUP BY source_note_id
) outbound ON outbound.source_note_id = n.id
LEFT JOIN (
SELECT target_note_id, COUNT(*) AS cnt FROM note_links
WHERE target_note_id IS NOT NULL GROUP BY target_note_id
) inbound ON inbound.target_note_id = n.id
WHERE n.user_id = $1
ORDER BY n.updated_at DESC`, userID)
if err != nil {
return nil, err
}
defer nodeRows.Close()
for nodeRows.Next() {
var node models.NoteGraphNode
var tags []string
if err := nodeRows.Scan(&node.ID, &node.Title, &node.FolderPath,
pq.Array(&tags), &node.UpdatedAt, &node.LinkCount); err != nil {
return nil, err
}
if tags == nil {
tags = []string{}
}
node.Tags = tags
graph.Nodes = append(graph.Nodes, node)
}
if err := nodeRows.Err(); err != nil {
return nil, err
}
// Resolved edges
edgeRows, err := DB.QueryContext(ctx, `
SELECT nl.source_note_id, nl.target_note_id, nl.target_title, 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`, userID)
if err != nil {
return nil, err
}
defer edgeRows.Close()
for edgeRows.Next() {
var edge models.NoteGraphEdge
if err := edgeRows.Scan(&edge.Source, &edge.Target,
&edge.Title, &edge.IsTransclusion); err != nil {
return nil, err
}
graph.Edges = append(graph.Edges, edge)
}
if err := edgeRows.Err(); err != nil {
return nil, err
}
// Unresolved links (dangling)
danglingRows, err := DB.QueryContext(ctx, `
SELECT nl.source_note_id, nl.target_title
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 NULL`, userID)
if err != nil {
return nil, err
}
defer danglingRows.Close()
for danglingRows.Next() {
var d models.NoteGraphDangling
if err := danglingRows.Scan(&d.Source, &d.Title); err != nil {
return nil, err
}
graph.Unresolved = append(graph.Unresolved, d)
}
return graph, danglingRows.Err()
}

View File

@@ -22,6 +22,7 @@ func NewStores(db *sql.DB) store.Stores {
Messages: NewMessageStore(),
Audit: NewAuditStore(),
Notes: NewNoteStore(),
NoteLinks: NewNoteLinkStore(),
GlobalConfig: NewGlobalConfigStore(),
Usage: NewUsageStore(),
Pricing: NewPricingStore(),

View File

@@ -22,11 +22,12 @@ func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
n.CreatedAt = now
n.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO notes (id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
INSERT INTO notes (id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
n.ID, n.UserID, n.Title, n.Content, n.FolderPath,
ArrayToJSON(n.Tags), ToJSON(n.Metadata),
models.NullString(n.SourceChannelID), models.NullString(n.TeamID),
models.NullString(n.SourceChannelID), models.NullString(n.SourceMessageID),
models.NullString(n.TeamID),
now.Format(timeFmt), now.Format(timeFmt),
)
return err
@@ -34,21 +35,22 @@ func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) {
var n models.Note
var sourceChannelID, teamID sql.NullString
var sourceChannelID, sourceMessageID, teamID sql.NullString
var tagsJSON, metadataJSON string
err := DB.QueryRowContext(ctx, `
SELECT id, user_id, title, content, folder_path, tags, metadata,
source_channel_id, team_id, created_at, updated_at
source_channel_id, source_message_id, team_id, created_at, updated_at
FROM notes WHERE id = ?`, id).Scan(
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
&tagsJSON, &metadataJSON,
&sourceChannelID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt),
&sourceChannelID, &sourceMessageID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt),
)
if err != nil {
return nil, err
}
n.Tags = ScanArray(tagsJSON)
n.SourceChannelID = NullableStringPtr(sourceChannelID)
n.SourceMessageID = NullableStringPtr(sourceMessageID)
n.TeamID = NullableStringPtr(teamID)
json.Unmarshal([]byte(metadataJSON), &n.Metadata)
return &n, nil
@@ -82,7 +84,7 @@ func (s *NoteStore) Delete(ctx context.Context, id string) error {
func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.NoteListOptions) ([]models.Note, int, error) {
b := NewSelect(
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at",
"notes",
).Where("user_id = ?", userID)
@@ -126,7 +128,7 @@ func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store
}
b := NewSelect(
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at",
"notes",
).Where("user_id = ?", userID)
@@ -153,6 +155,32 @@ func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store
return s.scanNotes(rows, total)
}
func (s *NoteStore) SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error) {
if limit <= 0 || limit > 50 {
limit = 10
}
rows, err := DB.QueryContext(ctx, `
SELECT id, title, folder_path
FROM notes
WHERE user_id = ? AND title LIKE '%' || ? || '%' COLLATE NOCASE
ORDER BY updated_at DESC
LIMIT ?`, userID, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var results []models.Note
for rows.Next() {
var n models.Note
if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath); err != nil {
return nil, err
}
results = append(results, n)
}
return results, rows.Err()
}
func (s *NoteStore) BulkDelete(ctx context.Context, ids []string, userID string) (int, error) {
if len(ids) == 0 {
return 0, nil
@@ -181,16 +209,17 @@ func (s *NoteStore) scanNotes(rows *sql.Rows, total int) ([]models.Note, int, er
var result []models.Note
for rows.Next() {
var n models.Note
var sourceChannelID, teamID sql.NullString
var sourceChannelID, sourceMessageID, teamID sql.NullString
var tagsJSON, metadataJSON string
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
&tagsJSON, &metadataJSON,
&sourceChannelID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt))
&sourceChannelID, &sourceMessageID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt))
if err != nil {
return nil, 0, err
}
n.Tags = ScanArray(tagsJSON)
n.SourceChannelID = NullableStringPtr(sourceChannelID)
n.SourceMessageID = NullableStringPtr(sourceMessageID)
n.TeamID = NullableStringPtr(teamID)
json.Unmarshal([]byte(metadataJSON), &n.Metadata)
result = append(result, n)

View File

@@ -0,0 +1,182 @@
package sqlite
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type NoteLinkStore struct{}
func NewNoteLinkStore() *NoteLinkStore { return &NoteLinkStore{} }
func (s *NoteLinkStore) ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error {
tx, err := DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Delete existing links for this source note
if _, err := tx.ExecContext(ctx,
"DELETE FROM note_links WHERE source_note_id = ?", sourceNoteID); err != nil {
return err
}
// Insert new links
if len(links) > 0 {
stmt, err := tx.PrepareContext(ctx, `
INSERT OR IGNORE INTO note_links (source_note_id, target_note_id, target_title, display_text, is_transclusion)
VALUES (?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, l := range links {
var targetID interface{}
if l.TargetNoteID != nil {
targetID = *l.TargetNoteID
}
isTransclusion := 0
if l.IsTransclusion {
isTransclusion = 1
}
if _, err := stmt.ExecContext(ctx,
sourceNoteID, targetID, l.TargetTitle, l.DisplayText, isTransclusion,
); err != nil {
return err
}
}
}
return tx.Commit()
}
func (s *NoteLinkStore) ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error {
_, err := DB.ExecContext(ctx, `
UPDATE note_links
SET target_note_id = ?
WHERE target_note_id IS NULL
AND LOWER(target_title) = LOWER(?)
AND source_note_id IN (SELECT id FROM notes WHERE user_id = ?)`,
targetNoteID, title, userID)
return err
}
func (s *NoteLinkStore) Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error) {
rows, err := DB.QueryContext(ctx, `
SELECT n.id, n.title, n.folder_path, n.updated_at, COALESCE(nl.display_text, '')
FROM note_links nl
JOIN notes n ON n.id = nl.source_note_id
WHERE nl.target_note_id = ?
ORDER BY n.updated_at DESC`, noteID)
if err != nil {
return nil, err
}
defer rows.Close()
var results []models.NoteLinkResult
for rows.Next() {
var r models.NoteLinkResult
if err := rows.Scan(&r.SourceNoteID, &r.Title, &r.FolderPath,
st(&r.UpdatedAt), &r.DisplayText); err != nil {
return nil, err
}
results = append(results, r)
}
return results, rows.Err()
}
func (s *NoteLinkStore) Graph(ctx context.Context, userID string) (*models.NoteGraph, error) {
graph := &models.NoteGraph{
Nodes: make([]models.NoteGraphNode, 0),
Edges: make([]models.NoteGraphEdge, 0),
Unresolved: make([]models.NoteGraphDangling, 0),
}
// Nodes: all user's notes with link counts
nodeRows, err := DB.QueryContext(ctx, `
SELECT n.id, n.title, n.folder_path, n.tags, n.updated_at,
COALESCE(outbound.cnt, 0) + COALESCE(inbound.cnt, 0) AS link_count
FROM notes n
LEFT JOIN (
SELECT source_note_id, COUNT(*) AS cnt FROM note_links
WHERE target_note_id IS NOT NULL GROUP BY source_note_id
) outbound ON outbound.source_note_id = n.id
LEFT JOIN (
SELECT target_note_id, COUNT(*) AS cnt FROM note_links
WHERE target_note_id IS NOT NULL GROUP BY target_note_id
) inbound ON inbound.target_note_id = n.id
WHERE n.user_id = ?
ORDER BY n.updated_at DESC`, userID)
if err != nil {
return nil, err
}
defer nodeRows.Close()
for nodeRows.Next() {
var node models.NoteGraphNode
var tagsJSON string
if err := nodeRows.Scan(&node.ID, &node.Title, &node.FolderPath,
&tagsJSON, &node.UpdatedAt, &node.LinkCount); err != nil {
return nil, err
}
node.Tags = ScanArray(tagsJSON)
if node.Tags == nil {
node.Tags = []string{}
}
graph.Nodes = append(graph.Nodes, node)
}
if err := nodeRows.Err(); err != nil {
return nil, err
}
// Resolved edges
edgeRows, err := DB.QueryContext(ctx, `
SELECT nl.source_note_id, nl.target_note_id, nl.target_title, nl.is_transclusion
FROM note_links nl
JOIN notes n ON n.id = nl.source_note_id
WHERE n.user_id = ?
AND nl.target_note_id IS NOT NULL`, userID)
if err != nil {
return nil, err
}
defer edgeRows.Close()
for edgeRows.Next() {
var edge models.NoteGraphEdge
var isTransclusion int
if err := edgeRows.Scan(&edge.Source, &edge.Target,
&edge.Title, &isTransclusion); err != nil {
return nil, err
}
edge.IsTransclusion = isTransclusion != 0
graph.Edges = append(graph.Edges, edge)
}
if err := edgeRows.Err(); err != nil {
return nil, err
}
// Unresolved links (dangling)
danglingRows, err := DB.QueryContext(ctx, `
SELECT nl.source_note_id, nl.target_title
FROM note_links nl
JOIN notes n ON n.id = nl.source_note_id
WHERE n.user_id = ?
AND nl.target_note_id IS NULL`, userID)
if err != nil {
return nil, err
}
defer danglingRows.Close()
for danglingRows.Next() {
var d models.NoteGraphDangling
if err := danglingRows.Scan(&d.Source, &d.Title); err != nil {
return nil, err
}
graph.Unresolved = append(graph.Unresolved, d)
}
return graph, danglingRows.Err()
}

View File

@@ -22,6 +22,7 @@ func NewStores(db *sql.DB) store.Stores {
Messages: NewMessageStore(),
Audit: NewAuditStore(),
Notes: NewNoteStore(),
NoteLinks: NewNoteLinkStore(),
GlobalConfig: NewGlobalConfigStore(),
Usage: NewUsageStore(),
Pricing: NewPricingStore(),

View File

@@ -1870,6 +1870,128 @@ select option { background: var(--bg-surface); color: var(--text); }
display: flex; gap: 8px; margin-top: 12px;
}
/* ── Note Backlinks ──────────────────────── */
.note-backlinks {
margin-top: 16px; border-top: 1px solid var(--border); padding-top: 12px;
}
.note-backlinks-header {
display: flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--text-2); cursor: pointer;
padding: 4px 0; user-select: none;
}
.note-backlinks-header:hover { color: var(--text); }
.note-backlinks-header .badge {
background: var(--accent); color: #fff; font-size: 10px;
padding: 1px 6px; border-radius: 10px;
}
.note-backlinks-list { margin-top: 6px; }
.note-backlink-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; border-radius: var(--radius); cursor: pointer;
font-size: 13px; transition: background var(--transition);
}
.note-backlink-item:hover { background: var(--bg-hover); }
.note-backlink-title { color: var(--accent); font-weight: 500; }
.note-backlink-folder { font-size: 11px; color: var(--text-3); }
/* ── Wikilink Chips (Read Mode) ──────────── */
.wikilink-chip {
display: inline-block; padding: 1px 6px; margin: 0 1px;
border-radius: 4px; cursor: pointer; font-size: 0.9em;
font-weight: 500; text-decoration: none; vertical-align: baseline;
background: rgba(var(--accent-rgb, 99, 102, 241), 0.15);
color: var(--accent, #6366f1);
transition: background var(--transition);
}
.wikilink-chip:hover {
background: rgba(var(--accent-rgb, 99, 102, 241), 0.25);
}
.wikilink-transclusion {
border-left: 2px solid var(--accent, #6366f1);
font-style: italic;
}
/* ── Transclusion Embeds ─────────────────── */
.transclusion-embed {
margin: 8px 0; border: 1px solid var(--border);
border-left: 3px solid var(--accent, #6366f1);
border-radius: var(--radius); overflow: hidden;
background: var(--bg-surface);
}
.transclusion-header {
padding: 6px 10px; background: var(--bg-2);
border-bottom: 1px solid var(--border); font-size: 12px;
}
.transclusion-content {
padding: 10px 12px; font-size: 13px; line-height: 1.5;
}
.transclusion-content .msg-text { margin: 0; }
/* ── Graph View ──────────────────────────── */
.notes-graph-view {
display: flex; flex-direction: column; height: 100%;
}
.notes-graph-toolbar {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border-bottom: 1px solid var(--border);
background: var(--bg-surface); flex-shrink: 0;
}
.notes-graph-stats {
flex: 1; text-align: center; font-size: 11px; color: var(--text-3);
}
#noteGraphCanvas {
flex: 1; width: 100%; min-height: 200px;
background: var(--bg);
}
/* ── Daily Note Navigation ───────────────── */
.note-daily-nav {
display: flex; align-items: center; justify-content: center;
gap: 12px; padding: 6px 10px;
border-bottom: 1px solid var(--border); background: var(--bg-surface);
}
.note-daily-date {
font-size: 13px; font-weight: 500; color: var(--text);
min-width: 100px; text-align: center;
}
/* ── Save-to-Note Modal ──────────────────── */
.save-to-note-modal {
max-width: 460px; width: 90%;
}
.save-to-note-modal h3 { margin: 0 0 12px; font-size: 16px; }
.save-note-mode-toggle {
display: flex; gap: 16px; margin-bottom: 12px; font-size: 13px;
}
.save-note-mode-toggle label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
.save-note-preview {
max-height: 120px; overflow-y: auto; padding: 8px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); font-size: 12px; line-height: 1.5;
}
.save-note-search-results {
max-height: 160px; overflow-y: auto; margin-top: 4px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-surface);
}
.save-note-search-results:empty { display: none; }
.save-note-result {
padding: 6px 10px; cursor: pointer; font-size: 13px;
transition: background var(--transition);
}
.save-note-result:hover, .save-note-result.selected {
background: var(--bg-hover);
}
.save-note-result.selected {
border-left: 3px solid var(--accent); font-weight: 500;
}
/* ── Sidebar Search ──────────────────────── */
.sidebar-search {

View File

@@ -30,12 +30,13 @@ import { emacs } from '@replit/codemirror-emacs';
import { codeEditor } from './code-editor.mjs';
import { chatInput } from './chat-input.mjs';
import { noteEditor } from './note-editor.mjs';
// ── Expose on window ────────────────────────
window.CM = {
// Version (injected by the build script or matched to app version)
version: '0.17.2',
version: '0.17.3',
// Low-level access (for advanced use / debug console)
EditorView,
@@ -49,6 +50,10 @@ window.CM = {
// CM.chatInput(container, { placeholder, onSubmit, onChange, maxHeight, darkMode })
chatInput,
// Factory: rich markdown note editor with wikilinks
// CM.noteEditor(container, { value, darkMode, onChange, onLink, linkCompleter })
noteEditor,
// Bundled language modes (for reference / dynamic use)
languages: {
javascript, json, markdown, sql, html, css, yaml, go, python, rust,

207
src/editor/note-editor.mjs Normal file
View File

@@ -0,0 +1,207 @@
// ==========================================
// Chat Switchboard — CM6 Note Editor Factory
// ==========================================
// Rich markdown editor for the notes panel.
// Decorates headings, bold, italic, code blocks inline.
// Integrates wikilink autocomplete and chip rendering.
//
// Usage:
// const editor = CM.noteEditor(container, {
// value: '# My Note\n\nSome content',
// darkMode: true,
// onChange: (text) => { ... },
// onLink: (title) => { ... },
// linkCompleter: async (query) => [{ label, id }],
// });
//
// editor.getValue()
// editor.setValue(str)
// editor.focus()
// editor.destroy()
// ==========================================
import { EditorView, keymap, drawSelection, dropCursor,
highlightSpecialChars, ViewPlugin, Decoration } from '@codemirror/view';
import { EditorState, RangeSetBuilder } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { syntaxHighlighting, defaultHighlightStyle, syntaxTree } from '@codemirror/language';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { noteEditorTheme } from './theme.mjs';
import { wikilinkExtension } from './wikilink.mjs';
// ── Markdown Live Preview Decorations ───────
/**
* ViewPlugin that styles markdown headings, code blocks, etc.
* at their rendered sizes inside the editor (live preview).
*/
const mdPreviewPlugin = ViewPlugin.fromClass(class {
decorations;
constructor(view) {
this.decorations = this.build(view);
}
update(update) {
if (update.docChanged || update.viewportChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state)) {
this.decorations = this.build(update.view);
}
}
build(view) {
const builder = new RangeSetBuilder();
const tree = syntaxTree(view.state);
const doc = view.state.doc;
tree.iterate({
enter(node) {
// Heading decorations
if (node.name.startsWith('ATXHeading')) {
const level = parseInt(node.name.replace('ATXHeading', ''), 10) || 1;
const line = doc.lineAt(node.from);
builder.add(line.from, line.from,
Decoration.line({ class: `cm-md-heading cm-md-h${level}` }));
}
// Fenced code block decorations
if (node.name === 'FencedCode') {
const startLine = doc.lineAt(node.from);
const endLine = doc.lineAt(node.to);
for (let ln = startLine.number; ln <= endLine.number; ln++) {
const line = doc.line(ln);
let cls = 'cm-codeblock cm-codeblock-mid';
if (ln === startLine.number) cls = 'cm-codeblock cm-codeblock-open';
if (ln === endLine.number) cls = 'cm-codeblock cm-codeblock-close';
if (startLine.number === endLine.number) cls = 'cm-codeblock cm-codeblock-open cm-codeblock-close';
builder.add(line.from, line.from, Decoration.line({ class: cls }));
}
}
// Blockquote decorations
if (node.name === 'Blockquote') {
const startLine = doc.lineAt(node.from);
const endLine = doc.lineAt(node.to);
for (let ln = startLine.number; ln <= endLine.number; ln++) {
const line = doc.line(ln);
builder.add(line.from, line.from,
Decoration.line({ class: 'cm-md-blockquote' }));
}
}
},
});
return builder.finish();
}
}, {
decorations: (v) => v.decorations,
});
// ── Note Editor Factory ─────────────────────
/**
* Create a rich markdown note editor with wikilink support.
*
* @param {HTMLElement} target - Container element
* @param {Object} opts
* @param {string} [opts.value='']
* @param {boolean} [opts.darkMode]
* @param {Function} [opts.onChange] - Called with text on every edit
* @param {Function} [opts.onLink] - Called with (title) when a [[link]] is clicked
* @param {Function} [opts.linkCompleter] - async (query) => [{ label, id }]
* @returns {{ getValue, setValue, focus, getView, destroy }}
*/
export function noteEditor(target, opts = {}) {
const {
value = '',
darkMode = document.documentElement.getAttribute('data-theme') !== 'light',
onChange = null,
onLink = null,
linkCompleter = null,
} = opts;
const extensions = [
// Markdown highlighting with live preview
markdown({ base: markdownLanguage }),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
mdPreviewPlugin,
// Wikilink support
...wikilinkExtension({ onLink, linkCompleter }),
// Base editing
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
closeBrackets(),
highlightSelectionMatches(),
// Theme — note-specific (no line numbers, full-height)
noteEditorTheme,
// Keymaps
keymap.of([
indentWithTab,
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...searchKeymap,
]),
// Spell check
EditorView.contentAttributes.of({
spellcheck: 'true',
autocapitalize: 'sentences',
autocorrect: 'on',
}),
// Line wrapping
EditorView.lineWrapping,
];
// Change listener
if (onChange) {
extensions.push(EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
}));
}
const view = new EditorView({
state: EditorState.create({ doc: value, extensions }),
parent: target,
});
// ── Public API ──────────────────────────
return {
getValue() {
return view.state.doc.toString();
},
setValue(text) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text || '' },
});
},
focus() {
view.focus();
},
/** Access the underlying EditorView */
getView() {
return view;
},
/** Clean up */
destroy() {
view.destroy();
},
};
}

View File

@@ -184,3 +184,97 @@ export const chatInputTheme = EditorView.theme({
color: 'var(--text-3, #6b6b7b)',
},
});
/**
* Theme for the note editor — full-height, no gutters,
* with markdown live preview styling (heading sizes, blockquotes).
*/
export const noteEditorTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--bg-surface, var(--bg))',
color: 'var(--text)',
fontSize: 'var(--msg-font, 14px)',
minHeight: '200px',
maxHeight: '60vh',
},
'.cm-content': {
fontFamily: 'var(--font, system-ui, sans-serif)',
caretColor: 'var(--accent)',
padding: '8px 0',
lineHeight: '1.6',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--accent)',
borderLeftWidth: '2px',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: 'var(--accent-muted, rgba(99, 102, 241, 0.2))',
},
'.cm-line': {
padding: '0 4px',
},
'.cm-scroller': {
fontFamily: 'var(--font, system-ui, sans-serif)',
overflow: 'auto',
},
// Tooltip/autocomplete
'.cm-tooltip': {
backgroundColor: 'var(--bg-2)',
color: 'var(--text)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius, 4px)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
},
'.cm-tooltip-autocomplete > ul > li': {
padding: '4px 8px',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--accent)',
color: '#fff',
},
// ── Markdown live preview ──
'.cm-md-heading': {
fontWeight: 'bold',
},
'.cm-md-h1': {
fontSize: '1.6em',
lineHeight: '1.3',
},
'.cm-md-h2': {
fontSize: '1.35em',
lineHeight: '1.3',
},
'.cm-md-h3': {
fontSize: '1.15em',
lineHeight: '1.4',
},
'.cm-md-blockquote': {
borderLeft: '3px solid var(--accent, #6c9fff)',
paddingLeft: '12px',
color: 'var(--text-2, #999)',
fontStyle: 'italic',
},
// Fenced code blocks
'.cm-codeblock': {
backgroundColor: 'var(--bg-2, #1e1e2e)',
fontFamily: 'var(--mono, monospace)',
fontSize: '13px',
padding: '0 10px',
borderLeft: '2px solid var(--accent, #6c9fff)',
},
'.cm-codeblock.cm-codeblock-open': {
borderTopLeftRadius: 'var(--radius, 6px)',
borderTopRightRadius: 'var(--radius, 6px)',
paddingTop: '6px',
color: 'var(--text-3, #6b6b7b)',
},
'.cm-codeblock.cm-codeblock-close': {
borderBottomLeftRadius: 'var(--radius, 6px)',
borderBottomRightRadius: 'var(--radius, 6px)',
paddingBottom: '6px',
color: 'var(--text-3, #6b6b7b)',
},
});

203
src/editor/wikilink.mjs Normal file
View File

@@ -0,0 +1,203 @@
// ==========================================
// Chat Switchboard — CM6 Wikilink Extension
// ==========================================
// Provides:
// 1. ViewPlugin that decorates [[Title]] as clickable chips
// 2. Autocomplete source triggered by [[
// 3. Distinct styling for ![[transclusion]] markers
//
// Usage:
// import { wikilinkExtension } from './wikilink.mjs';
// const ext = wikilinkExtension({
// onLink: (title) => { ... },
// linkCompleter: async (query) => [{ label, id }],
// });
// ==========================================
import {
ViewPlugin, Decoration, WidgetType, EditorView,
} from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';
import { autocompletion } from '@codemirror/autocomplete';
// ── Wikilink Regex ──────────────────────────
const WIKILINK_RE = /(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g;
// ── Widget: Clickable Link Chip ─────────────
class WikilinkWidget extends WidgetType {
constructor(title, displayText, isTransclusion, onLink) {
super();
this.title = title;
this.displayText = displayText || title;
this.isTransclusion = isTransclusion;
this.onLink = onLink;
}
toDOM() {
const chip = document.createElement('span');
chip.className = this.isTransclusion
? 'cm-wikilink cm-wikilink-transclusion'
: 'cm-wikilink';
chip.textContent = (this.isTransclusion ? '↗ ' : '') + this.displayText;
chip.title = this.isTransclusion
? `Embed: ${this.title}`
: `Link: ${this.title}`;
chip.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (this.onLink) this.onLink(this.title);
});
return chip;
}
eq(other) {
return this.title === other.title
&& this.displayText === other.displayText
&& this.isTransclusion === other.isTransclusion;
}
ignoreEvent() { return false; }
}
// ── ViewPlugin: Decorate [[links]] ──────────
function wikilinkDecoPlugin(onLink) {
return ViewPlugin.fromClass(class {
decorations;
constructor(view) {
this.decorations = this.build(view);
}
update(update) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.build(update.view);
}
}
build(view) {
const builder = new RangeSetBuilder();
const doc = view.state.doc;
const text = doc.toString();
let match;
WIKILINK_RE.lastIndex = 0;
while ((match = WIKILINK_RE.exec(text)) !== null) {
const from = match.index;
const to = from + match[0].length;
// Don't decorate if cursor is inside the link (let user edit)
const sel = view.state.selection.main;
if (sel.from >= from && sel.from <= to) continue;
const isTransclusion = match[1] === '!';
const title = match[2].trim();
const displayText = match[3] ? match[3].trim() : '';
builder.add(from, to, Decoration.replace({
widget: new WikilinkWidget(title, displayText, isTransclusion, onLink),
}));
}
return builder.finish();
}
}, {
decorations: (v) => v.decorations,
});
}
// ── Autocomplete: [[ trigger ────────────────
function wikilinkAutocomplete(linkCompleter) {
return autocompletion({
override: [
async (ctx) => {
// Look for [[ before the cursor
const line = ctx.state.doc.lineAt(ctx.pos);
const textBefore = ctx.state.doc.sliceString(line.from, ctx.pos);
const triggerMatch = textBefore.match(/\[\[([^\]]*?)$/);
if (!triggerMatch) return null;
const query = triggerMatch[1];
const from = ctx.pos - query.length;
if (!linkCompleter) return null;
try {
const results = await linkCompleter(query);
if (!results || results.length === 0) return null;
return {
from,
options: results.map(r => ({
label: r.label || r.title,
apply: (view, completion, from, to) => {
// Replace query text with completed title + closing ]]
const insert = completion.label + ']]';
view.dispatch({
changes: { from, to, insert },
selection: { anchor: from + insert.length },
});
},
})),
};
} catch (e) {
console.warn('[wikilink] autocomplete error:', e);
return null;
}
},
],
activateOnTyping: true,
});
}
// ── Theme: Wikilink Chip Styles ─────────────
const wikilinkTheme = EditorView.baseTheme({
'.cm-wikilink': {
display: 'inline-block',
padding: '1px 6px',
margin: '0 1px',
borderRadius: '4px',
backgroundColor: 'rgba(var(--accent-rgb, 99, 102, 241), 0.15)',
color: 'var(--accent, #6366f1)',
cursor: 'pointer',
fontSize: '0.9em',
fontWeight: '500',
textDecoration: 'none',
verticalAlign: 'baseline',
'&:hover': {
backgroundColor: 'rgba(var(--accent-rgb, 99, 102, 241), 0.25)',
},
},
'.cm-wikilink-transclusion': {
borderLeft: '2px solid var(--accent, #6366f1)',
fontStyle: 'italic',
},
});
// ── Public: Combined Extension ──────────────
/**
* Create the wikilink CM6 extension bundle.
*
* @param {Object} opts
* @param {Function} [opts.onLink] - Called with (title) when a link chip is clicked
* @param {Function} [opts.linkCompleter] - async (query) => [{ label, id }]
* @returns {Extension[]} Array of CM6 extensions
*/
export function wikilinkExtension(opts = {}) {
const extensions = [
wikilinkDecoPlugin(opts.onLink || null),
wikilinkTheme,
];
if (opts.linkCompleter) {
extensions.push(wikilinkAutocomplete(opts.linkCompleter));
}
return extensions;
}

View File

@@ -237,6 +237,8 @@
</div>
<div class="notes-actions-bar">
<button class="btn-small" id="notesSelectModeBtn">Select</button>
<button class="btn-small" id="notesGraphBtn" title="Knowledge graph">Graph</button>
<button class="btn-small" id="notesTodayBtn" title="Today's daily note">Today</button>
<button class="btn-small btn-primary" id="notesNewBtn">+ New Note</button>
</div>
<div class="notes-selection-bar" id="notesSelectionBar" style="display:none">
@@ -250,6 +252,19 @@
<div class="notes-empty">No notes yet. Create one or ask the AI to save a note.</div>
</div>
</div>
<!-- Graph view (hidden by default) -->
<div id="notesGraphView" class="notes-graph-view" style="display:none">
<div class="notes-graph-toolbar">
<button class="btn-small" onclick="closeNoteGraph()">← List</button>
<span class="notes-graph-stats">
<span id="graphNodeCount">0</span> notes ·
<span id="graphEdgeCount">0</span> links
</span>
<button class="btn-small" onclick="_graphToggleOrphans()" title="Toggle unresolved links">Ghosts</button>
<button class="btn-small" onclick="_graphResetZoom()" title="Reset zoom">Reset</button>
</div>
<canvas id="noteGraphCanvas"></canvas>
</div>
<!-- Editor view (hidden by default) -->
<div id="notesEditorView" style="display:none">
<div class="notes-editor">
@@ -265,9 +280,16 @@
<h3 class="note-read-title" id="noteReadTitle"></h3>
<div class="note-read-meta" id="noteReadMeta"></div>
<div class="note-read-content msg-text" id="noteReadContent"></div>
<!-- Backlinks panel -->
<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>
<div class="notes-editor-actions">
<button class="btn-small" id="noteCopyBtn" onclick="copyNoteContent()">Copy</button>
<button class="btn-small btn-primary" id="noteEditBtn2">Edit</button>
<button class="btn-small btn-danger" id="noteDeleteBtn2" style="display:none">Delete</button>
</div>
</div>
@@ -278,7 +300,7 @@
<div class="form-group"><input type="text" id="noteEditorFolder" placeholder="Folder (e.g. /work/meetings)" class="notes-folder-input"></div>
<div class="form-group"><input type="text" id="noteEditorTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
</div>
<div class="form-group"><textarea id="noteEditorContent" rows="12" placeholder="Note content (Markdown supported)..." class="notes-content-input"></textarea></div>
<div class="form-group"><div id="noteEditorContentContainer" class="notes-content-input"></div></div>
<div class="notes-editor-actions">
<button class="btn-small btn-primary" id="noteSaveBtn">Save</button>
<button class="btn-small" id="noteCancelEditBtn" style="display:none">Cancel</button>
@@ -997,6 +1019,49 @@
</div>
<!-- Command Palette (Ctrl+K) -->
<!-- ── Save to Note Modal ──────────────────── -->
<div class="modal-overlay" id="saveToNoteModal" style="display:none">
<div class="modal modal-narrow">
<div class="modal-header"><h2>Save to Note</h2><button class="modal-close" onclick="closeSaveToNoteModal()"></button></div>
<div class="modal-body">
<div class="form-group">
<label>Title</label>
<input type="text" id="saveToNoteTitle" placeholder="Note title" class="notes-title-input">
</div>
<div class="form-row">
<div class="form-group">
<label>Folder</label>
<input type="text" id="saveToNoteFolder" placeholder="/ (root)" class="notes-folder-input">
</div>
<div class="form-group">
<label>Tags</label>
<input type="text" id="saveToNoteTags" placeholder="comma-separated" class="notes-tags-input">
</div>
</div>
<div class="form-group">
<label class="save-to-note-toggle">
<input type="checkbox" id="saveToNoteAppendToggle" onchange="_toggleAppendMode(this.checked)">
Append to existing note
</label>
</div>
<div id="saveToNoteAppendPicker" style="display:none" class="form-group">
<label>Target note</label>
<input type="text" id="saveToNoteAppendSearch" placeholder="Search notes..." class="notes-title-input" oninput="_searchAppendTarget(this.value)">
<div id="saveToNoteAppendResults" class="save-to-note-results"></div>
</div>
<div class="form-group">
<label>Preview</label>
<div id="saveToNotePreview" class="save-to-note-preview msg-text"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn-small" onclick="closeSaveToNoteModal()">Cancel</button>
<button class="btn-small btn-primary" id="saveToNoteConfirmBtn" onclick="_confirmSaveToNote()">Create Note</button>
</div>
</div>
</div>
<div class="cmd-palette-overlay" id="cmdPalette">
<div class="cmd-palette">
<div class="cmd-input-wrap">
@@ -1031,6 +1096,7 @@
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>
<script src="js/tokens.js?v=%%APP_VERSION%%"></script>
<script src="js/notes.js?v=%%APP_VERSION%%"></script>
<script src="js/note-graph.js?v=%%APP_VERSION%%"></script>
<script src="js/attachments.js?v=%%APP_VERSION%%"></script>
<script src="js/tools-toggle.js?v=%%APP_VERSION%%"></script>
<script src="js/knowledge-ui.js?v=%%APP_VERSION%%"></script>

View File

@@ -489,10 +489,12 @@ const API = {
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
return this._get(url);
},
createNote(title, content, folderPath, tags) {
createNote(title, content, folderPath, tags, sourceChannelId, sourceMessageId) {
const body = { title, content };
if (folderPath) body.folder_path = folderPath;
if (tags?.length) body.tags = tags;
if (sourceChannelId) body.source_channel_id = sourceChannelId;
if (sourceMessageId) body.source_message_id = sourceMessageId;
return this._post('/api/v1/notes', body);
},
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
@@ -500,7 +502,10 @@ const API = {
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
searchNoteTitles(query, limit = 10) { return this._get(`/api/v1/notes/search-titles?q=${encodeURIComponent(query)}&limit=${limit}`); },
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
getNoteBacklinks(id) { return this._get(`/api/v1/notes/${id}/backlinks`); },
getNoteGraph() { return this._get('/api/v1/notes/graph'); },
// ── Attachments ─────────────────────────

490
src/js/note-graph.js Normal file
View File

@@ -0,0 +1,490 @@
// ==========================================
// Chat Switchboard — Note Graph (Canvas)
// ==========================================
// Force-directed graph visualization of note
// connections. Lazy-loaded — initializes only
// when the graph view is opened.
// ==========================================
var _graphData = null; // cached { nodes, edges, unresolved }
var _graphState = null; // { panX, panY, zoom, animId, canvas, ctx }
var _graphDirty = true; // invalidation flag
// ── Force Simulation Parameters ─────────────
const SIM = {
repulsion: 800,
attraction: 0.006,
edgeLength: 130,
damping: 0.88,
centerGravity: 0.012,
maxVelocity: 8,
ticksPerFrame: 3,
coolThreshold: 0.5, // total KE below this → stop sim
};
const BASE_RADIUS = 6;
// ── Color Palette for Folders ───────────────
const FOLDER_COLORS = [
'#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6',
'#8b5cf6', '#ef4444', '#06b6d4', '#84cc16', '#f97316',
];
var _folderColorMap = {};
function folderColor(path) {
if (!path || path === '/') return '#6b7280';
if (!_folderColorMap[path]) {
const idx = Object.keys(_folderColorMap).length % FOLDER_COLORS.length;
_folderColorMap[path] = FOLDER_COLORS[idx];
}
return _folderColorMap[path];
}
// ── Graph Initialization ────────────────────
async function openNoteGraph() {
const listView = document.getElementById('notesListView');
const editorView = document.getElementById('notesEditorView');
const graphView = document.getElementById('notesGraphView');
if (!graphView) return;
listView.style.display = 'none';
editorView.style.display = 'none';
graphView.style.display = '';
const canvas = document.getElementById('noteGraphCanvas');
if (!canvas) return;
// Size canvas to panel
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height - 40; // toolbar height
const ctx = canvas.getContext('2d');
_graphState = {
panX: 0, panY: 0, zoom: 1,
animId: null, canvas, ctx,
hoveredNode: null, dragNode: null,
isDragging: false, lastMouse: null,
showOrphans: true,
};
// Load data
try {
_graphData = await API.getNoteGraph();
_prepareGraphNodes(canvas);
_updateGraphStats();
_startSimulation();
_attachGraphListeners(canvas);
} catch (e) {
ctx.fillStyle = 'var(--text, #ccc)';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Failed to load graph: ' + e.message, canvas.width / 2, canvas.height / 2);
}
}
function closeNoteGraph() {
if (_graphState?.animId) {
cancelAnimationFrame(_graphState.animId);
_graphState.animId = null;
}
const graphView = document.getElementById('notesGraphView');
if (graphView) graphView.style.display = 'none';
document.getElementById('notesListView').style.display = '';
}
// ── Prepare Nodes with Physics State ────────
function _prepareGraphNodes(canvas) {
if (!_graphData) return;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
// Build lookup for edge source/target
const nodeMap = {};
for (const n of _graphData.nodes) {
n.x = cx + (Math.random() - 0.5) * canvas.width * 0.5;
n.y = cy + (Math.random() - 0.5) * canvas.height * 0.5;
n.vx = 0; n.vy = 0;
n.ax = 0; n.ay = 0;
n.pinned = false;
n.hovered = false;
nodeMap[n.id] = n;
}
// Resolve edge references
for (const e of _graphData.edges) {
e.sourceNode = nodeMap[e.source];
e.targetNode = nodeMap[e.target];
}
// Optionally include ghost nodes for unresolved links
_graphData._ghostNodes = [];
if (_graphData.unresolved) {
const ghostMap = {};
for (const d of _graphData.unresolved) {
const key = d.title.toLowerCase();
if (!ghostMap[key]) {
const ghost = {
id: '__ghost_' + key, title: d.title,
folder_path: '', tags: [], link_count: 0,
x: cx + (Math.random() - 0.5) * canvas.width * 0.4,
y: cy + (Math.random() - 0.5) * canvas.height * 0.4,
vx: 0, vy: 0, ax: 0, ay: 0,
pinned: false, hovered: false, isGhost: true,
};
ghostMap[key] = ghost;
_graphData._ghostNodes.push(ghost);
}
}
}
}
function _updateGraphStats() {
const nc = document.getElementById('graphNodeCount');
const ec = document.getElementById('graphEdgeCount');
if (nc) nc.textContent = _graphData?.nodes?.length || 0;
if (ec) ec.textContent = _graphData?.edges?.length || 0;
}
// ── Force Simulation Loop ───────────────────
function _startSimulation() {
if (!_graphData || !_graphState) return;
function frame() {
for (let t = 0; t < SIM.ticksPerFrame; t++) {
_tick();
}
_render();
// Check convergence
let totalKE = 0;
for (const n of _allNodes()) {
totalKE += n.vx * n.vx + n.vy * n.vy;
}
if (totalKE > SIM.coolThreshold || _graphState.dragNode) {
_graphState.animId = requestAnimationFrame(frame);
} else {
_graphState.animId = null;
// One final render
_render();
}
}
if (_graphState.animId) cancelAnimationFrame(_graphState.animId);
_graphState.animId = requestAnimationFrame(frame);
}
function _allNodes() {
if (!_graphData) return [];
const nodes = [..._graphData.nodes];
if (_graphState?.showOrphans !== false && _graphData._ghostNodes) {
nodes.push(..._graphData._ghostNodes);
}
return nodes;
}
function _tick() {
const nodes = _allNodes();
const edges = _graphData?.edges || [];
const canvas = _graphState?.canvas;
if (!canvas || nodes.length === 0) return;
// 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.sourceNode, t = e.targetNode;
if (!s || !t) continue;
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 + integration
const cx = canvas.width / 2, cy = canvas.height / 2;
for (const n of nodes) {
if (n.pinned) continue;
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;
}
}
function _clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
// ── Canvas Rendering ────────────────────────
function _render() {
const { ctx, canvas, panX, panY, zoom, hoveredNode, showOrphans } = _graphState;
if (!ctx || !_graphData) return;
const nodes = _allNodes();
const edges = _graphData.edges || [];
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(panX, panY);
ctx.scale(zoom, zoom);
// Edges
for (const e of edges) {
const s = e.sourceNode, t = e.targetNode;
if (!s || !t) continue;
const isHighlighted = hoveredNode &&
(s.id === hoveredNode.id || t.id === hoveredNode.id);
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(t.x, t.y);
if (e.is_transclusion) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = isHighlighted
? 'rgba(99, 102, 241, 0.8)' : 'rgba(99, 102, 241, 0.3)';
} else {
ctx.setLineDash([]);
ctx.strokeStyle = isHighlighted
? 'rgba(160, 160, 160, 0.8)' : 'rgba(120, 120, 120, 0.25)';
}
ctx.lineWidth = isHighlighted ? 2 : 1;
ctx.stroke();
}
ctx.setLineDash([]);
// Nodes
for (const n of nodes) {
if (n.isGhost && !showOrphans) continue;
const r = n.isGhost
? BASE_RADIUS * 0.6
: BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
const isHovered = hoveredNode && n.id === hoveredNode.id;
const isNeighbor = hoveredNode && _isNeighbor(hoveredNode, n);
const isDimmed = hoveredNode && !isHovered && !isNeighbor;
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
if (n.isGhost) {
ctx.fillStyle = isDimmed ? 'rgba(200, 50, 50, 0.1)' : 'rgba(200, 50, 50, 0.4)';
ctx.setLineDash([2, 2]);
ctx.strokeStyle = 'rgba(200, 50, 50, 0.5)';
} else if (isHovered) {
ctx.fillStyle = '#6366f1';
} else {
const color = folderColor(n.folder_path);
ctx.fillStyle = isDimmed ? _withAlpha(color, 0.15) : _withAlpha(color, 0.7);
ctx.strokeStyle = isDimmed ? 'rgba(255,255,255,0.05)' : 'rgba(255,255,255,0.15)';
}
ctx.fill();
if (!n.isGhost) {
ctx.setLineDash([]);
ctx.lineWidth = isHovered ? 2 : 1;
ctx.stroke();
} else {
ctx.stroke();
ctx.setLineDash([]);
}
// Labels
if (zoom > 0.5 && (isHovered || isNeighbor || !hoveredNode)) {
const fontSize = Math.max(10, 11 / zoom);
ctx.font = `${n.isGhost ? 'italic ' : ''}${fontSize}px var(--font, system-ui, sans-serif)`;
ctx.fillStyle = isDimmed ? 'rgba(200,200,200,0.2)' : 'rgba(220,220,220,0.9)';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const label = n.title.length > 30 ? n.title.slice(0, 28) + '…' : n.title;
ctx.fillText(label, n.x, n.y + r + 4);
}
}
ctx.restore();
}
function _isNeighbor(hovered, candidate) {
if (!_graphData) return false;
for (const e of _graphData.edges) {
if ((e.source === hovered.id && e.target === candidate.id) ||
(e.target === hovered.id && e.source === candidate.id)) {
return true;
}
}
return false;
}
function _withAlpha(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
// ── Interaction: Pan, Zoom, Drag, Click ─────
function _attachGraphListeners(canvas) {
// Mouse → canvas coordinates (accounting for pan/zoom)
function toGraph(e) {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - _graphState.panX) / _graphState.zoom;
const y = (e.clientY - rect.top - _graphState.panY) / _graphState.zoom;
return { x, y };
}
function hitTest(gx, gy) {
const nodes = _allNodes();
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i];
const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
const dx = n.x - gx, dy = n.y - gy;
if (dx * dx + dy * dy <= (r + 4) * (r + 4)) return n;
}
return null;
}
// Mousedown
canvas.addEventListener('mousedown', (e) => {
const g = toGraph(e);
const node = hitTest(g.x, g.y);
if (node && !node.isGhost) {
_graphState.dragNode = node;
node.pinned = true;
_graphState.isDragging = false;
}
_graphState.lastMouse = { x: e.clientX, y: e.clientY };
});
// Mousemove
canvas.addEventListener('mousemove', (e) => {
const g = toGraph(e);
if (_graphState.dragNode) {
_graphState.isDragging = true;
_graphState.dragNode.x = g.x;
_graphState.dragNode.y = g.y;
_graphState.dragNode.vx = 0;
_graphState.dragNode.vy = 0;
if (!_graphState.animId) _startSimulation();
} else if (_graphState.lastMouse && e.buttons === 1) {
// Pan
_graphState.panX += e.clientX - _graphState.lastMouse.x;
_graphState.panY += e.clientY - _graphState.lastMouse.y;
_graphState.lastMouse = { x: e.clientX, y: e.clientY };
_render();
} else {
// Hover
const node = hitTest(g.x, g.y);
if (node !== _graphState.hoveredNode) {
_graphState.hoveredNode = node;
canvas.style.cursor = node ? 'pointer' : 'default';
_render();
}
}
});
// Mouseup
canvas.addEventListener('mouseup', (e) => {
if (_graphState.dragNode) {
_graphState.dragNode.pinned = false;
// Click (not drag) → open note
if (!_graphState.isDragging && !_graphState.dragNode.isGhost) {
openNoteEditor(_graphState.dragNode.id);
}
_graphState.dragNode = null;
}
_graphState.lastMouse = null;
_graphState.isDragging = false;
});
// Wheel → zoom
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.92 : 1.08;
const newZoom = _clamp(_graphState.zoom * factor, 0.15, 4.0);
// Zoom toward cursor
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
_graphState.panX = mx - (mx - _graphState.panX) * (newZoom / _graphState.zoom);
_graphState.panY = my - (my - _graphState.panY) * (newZoom / _graphState.zoom);
_graphState.zoom = newZoom;
_render();
}, { passive: false });
// Resize observer — debounced to prevent notification loop
let _roFrame = null;
const ro = new ResizeObserver(() => {
if (_roFrame) return; // already scheduled
_roFrame = requestAnimationFrame(() => {
_roFrame = null;
const rect = canvas.parentElement.getBoundingClientRect();
const w = Math.round(rect.width);
const h = Math.round(rect.height - 40);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
_render();
}
});
});
ro.observe(canvas.parentElement);
}
function _graphResetZoom() {
if (!_graphState) return;
_graphState.panX = 0;
_graphState.panY = 0;
_graphState.zoom = 1;
_render();
}
function _graphToggleOrphans() {
if (!_graphState) return;
_graphState.showOrphans = !_graphState.showOrphans;
_render();
}
// ── Invalidation ────────────────────────────
function invalidateNoteGraph() {
_graphData = null;
_graphDirty = true;
_folderColorMap = {};
}

View File

@@ -7,6 +7,7 @@ var _editingNoteId = null;
var _notesSelectMode = false;
var _selectedNoteIds = new Set();
var _notesSort = 'updated_desc';
var _noteEditor = null; // CM6 noteEditor instance
// ── Notes ─────────────────────────────────────
@@ -172,7 +173,13 @@ async function loadNoteFolders() {
function showNotesList() {
document.getElementById('notesListView').style.display = '';
document.getElementById('notesEditorView').style.display = 'none';
const graphView = document.getElementById('notesGraphView');
if (graphView) graphView.style.display = 'none';
_editingNoteId = null;
_destroyNoteEditor();
// Reset list to loading state to prevent stale content flash
const list = document.getElementById('notesList');
if (list) list.innerHTML = '<div class="notes-loading">Loading…</div>';
}
var _currentNote = null; // cached note for read mode
@@ -211,14 +218,83 @@ function _populateEditFields(note) {
document.getElementById('noteEditorTitle').value = note.title || '';
document.getElementById('noteEditorFolder').value = note.folder_path || '';
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
document.getElementById('noteEditorContent').value = note.content || '';
_setNoteEditorContent(note.content || '');
}
function _clearEditFields() {
document.getElementById('noteEditorTitle').value = '';
document.getElementById('noteEditorFolder').value = '';
document.getElementById('noteEditorTags').value = '';
document.getElementById('noteEditorContent').value = '';
_setNoteEditorContent('');
}
/** Get content from CM6 editor or fallback textarea */
function _getNoteEditorContent() {
if (_noteEditor) return _noteEditor.getValue();
const ta = document.querySelector('#noteEditorContentContainer textarea');
return ta ? ta.value : '';
}
/** Set content in CM6 editor or fallback textarea */
function _setNoteEditorContent(text) {
if (_noteEditor) {
_noteEditor.setValue(text);
return;
}
// Lazy-init CM6 note editor
const container = document.getElementById('noteEditorContentContainer');
if (!container) return;
if (window.CM?.noteEditor) {
container.innerHTML = ''; // clear any previous
_noteEditor = CM.noteEditor(container, {
value: text,
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
onChange: null, // could wire auto-save later
onLink: (title) => {
_navigateToLinkedNote(title);
},
linkCompleter: async (query) => {
if (!query || query.length < 1) return [];
try {
const resp = await API.searchNoteTitles(query, 8);
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
} catch { return []; }
},
});
} else {
// Fallback: plain textarea
if (!container.querySelector('textarea')) {
container.innerHTML = '<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown..."></textarea>';
}
const ta = container.querySelector('textarea');
if (ta) ta.value = text;
}
}
/** Navigate to a note by title (from wikilink click) */
async function _navigateToLinkedNote(title) {
try {
const resp = await API.searchNoteTitles(title, 1);
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
if (match) {
await openNoteEditor(match.id);
} else {
// Offer to create
if (await showConfirm(`Note "${title}" not found. Create it?`)) {
const created = await API.createNote(title, `# ${title}\n\n`, '/', []);
await openNoteEditor(created.id);
}
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
}
/** Destroy CM6 editor on panel close */
function _destroyNoteEditor() {
if (_noteEditor) {
_noteEditor.destroy();
_noteEditor = null;
}
}
function _showNoteReadMode() {
@@ -239,6 +315,9 @@ function _showNoteReadMode() {
noteReadEl.innerHTML = formatMessage(n.content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
// Render [[wikilinks]] as clickable chips in read mode
_renderWikilinksInReadMode(noteReadEl);
// Show/hide delete
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
@@ -247,6 +326,12 @@ function _showNoteReadMode() {
document.getElementById('noteEditMode').style.display = 'none';
document.getElementById('noteEditBtn').style.display = '';
document.getElementById('notePreviewBtn').style.display = 'none';
// Load backlinks
if (_editingNoteId) _loadBacklinks(_editingNoteId);
// Show daily note navigation if applicable
_updateDailyNav(_currentNote);
}
function _showNoteEditMode() {
@@ -259,9 +344,9 @@ function _showNoteEditMode() {
}
function _showNotePreview() {
// Live preview from textarea without saving
// Live preview from editor without saving
const title = document.getElementById('noteEditorTitle').value.trim();
const content = document.getElementById('noteEditorContent').value;
const content = _getNoteEditorContent();
const folderVal = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
@@ -273,6 +358,7 @@ function _showNotePreview() {
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(document.getElementById('noteReadContent'));
_renderWikilinksInReadMode(document.getElementById('noteReadContent'));
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';
@@ -284,7 +370,7 @@ function _showNotePreview() {
async function saveNote() {
const title = document.getElementById('noteEditorTitle').value.trim();
const content = document.getElementById('noteEditorContent').value;
const content = _getNoteEditorContent();
const folder = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
@@ -305,6 +391,8 @@ async function saveNote() {
UI.toast('Note created', 'success');
_showNoteReadMode();
}
// Invalidate graph cache
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
} catch (e) { UI.toast(e.message, 'error'); }
}
@@ -314,17 +402,356 @@ async function deleteNote() {
try {
await API.deleteNote(_editingNoteId);
UI.toast('Note deleted', 'success');
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
showNotesList();
await loadNotesList();
await loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Backlinks ───────────────────
async function _loadBacklinks(noteId) {
const container = document.getElementById('noteBacklinks');
const listEl = document.getElementById('noteBacklinksList');
const countEl = document.getElementById('noteBacklinksCount');
if (!container || !listEl || !countEl) return;
try {
const resp = await API.getNoteBacklinks(noteId);
const links = resp.data || [];
countEl.textContent = links.length;
container.style.display = links.length > 0 ? '' : 'none';
listEl.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('');
} catch {
container.style.display = 'none';
}
}
function toggleBacklinks() {
const list = document.getElementById('noteBacklinksList');
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
}
// ── Wikilink Read-Mode Rendering ──
function _renderWikilinksInReadMode(containerEl) {
if (!containerEl) return;
const html = containerEl.innerHTML;
let transclusionId = 0;
// Replace [[Title|display]] and [[Title]] with clickable spans
// For ![[transclusion]], render a placeholder that gets filled async
const rendered = html.replace(
/(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
(match, bang, title, display) => {
const titleTrimmed = title.trim();
const displayText = display ? display.trim() : titleTrimmed;
if (bang === '!') {
const tid = `transclusion-${++transclusionId}`;
return `<div class="transclusion-embed" id="${tid}" data-title="${esc(titleTrimmed)}">
<div class="transclusion-header">
<span class="wikilink-chip wikilink-transclusion" onclick="_navigateToLinkedNote('${esc(titleTrimmed)}')" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span>
</div>
<div class="transclusion-content"><span class="text-muted">Loading…</span></div>
</div>`;
}
return `<span class="wikilink-chip" onclick="_navigateToLinkedNote('${esc(titleTrimmed)}')" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`;
}
);
containerEl.innerHTML = rendered;
// Async: resolve and embed transclusion content
_resolveTransclusions(containerEl);
}
/** Fetch content for each ![[embed]] placeholder — max depth 1 (no recursive transclusion) */
async function _resolveTransclusions(containerEl) {
const embeds = containerEl.querySelectorAll('.transclusion-embed');
if (!embeds.length) return;
// Cache to avoid duplicate fetches in same note
const cache = {};
for (const el of embeds) {
const title = el.dataset.title;
const contentEl = el.querySelector('.transclusion-content');
if (!title || !contentEl) continue;
try {
let note = cache[title.toLowerCase()];
if (!note) {
const resp = await API.searchNoteTitles(title, 1);
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
if (!match) {
contentEl.innerHTML = '<span class="text-muted">Note not found</span>';
continue;
}
note = await API.getNote(match.id);
cache[title.toLowerCase()] = note;
}
// Render embedded content (no recursive transclusion — strip ![[]] )
const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => {
const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || '';
return `[Embedded: ${inner}]`;
});
contentEl.innerHTML = formatMessage(safeContent);
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(contentEl);
} catch {
contentEl.innerHTML = '<span class="text-muted">Failed to load</span>';
}
}
}
// ── Daily Notes ─────────────────
async function openDailyNote() {
const today = new Date().toISOString().slice(0, 10);
const title = `Daily — ${today}`;
const folder = '/daily/';
try {
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 {
const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await API.createNote(title, content, folder, ['daily']);
await openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to open daily note: ' + e.message, 'error'); }
}
/** Detect if current note is a daily note */
function _isDailyNote(note) {
return note && /^Daily — \d{4}-\d{2}-\d{2}$/.test(note.title);
}
/** Show prev/next nav for daily notes */
function _updateDailyNav(note) {
let nav = document.getElementById('noteDailyNav');
if (!_isDailyNote(note)) {
if (nav) nav.style.display = 'none';
return;
}
// Ensure nav element exists
if (!nav) {
const toolbar = document.querySelector('.notes-editor-toolbar');
if (!toolbar) return;
nav = document.createElement('div');
nav.id = 'noteDailyNav';
nav.className = 'note-daily-nav';
nav.innerHTML = `
<button class="btn-small" id="dailyPrevBtn" title="Previous day"> Prev</button>
<span class="note-daily-date" id="dailyDateLabel"></span>
<button class="btn-small" id="dailyNextBtn" title="Next day">Next </button>
`;
toolbar.after(nav);
document.getElementById('dailyPrevBtn')?.addEventListener('click', () => _navigateDaily(-1));
document.getElementById('dailyNextBtn')?.addEventListener('click', () => _navigateDaily(1));
}
nav.style.display = '';
const dateStr = note.title.replace('Daily — ', '');
document.getElementById('dailyDateLabel').textContent = dateStr;
// Disable "Next" if it's today or future
const today = new Date().toISOString().slice(0, 10);
document.getElementById('dailyNextBtn').disabled = dateStr >= today;
}
/** Navigate to prev/next daily note */
async function _navigateDaily(offset) {
if (!_currentNote || !_isDailyNote(_currentNote)) return;
const dateStr = _currentNote.title.replace('Daily — ', '');
const d = new Date(dateStr + 'T12:00:00'); // noon to avoid DST edge
d.setDate(d.getDate() + offset);
const newDateStr = d.toISOString().slice(0, 10);
const title = `Daily — ${newDateStr}`;
try {
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 the daily note for that day
const content = `# ${newDateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await API.createNote(title, content, '/daily/', ['daily']);
await openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
}
// ── Save-to-Note (from chat messages) ──
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 msgId = msg.id || '';
const msgEl = msgId ? document.querySelector(`.message[data-msg-id="${msgId}"] .msg-text`) : null;
if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) {
const selected = sel.toString().trim();
if (selected) content = selected;
}
// Extract title from first line
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
const defaultTitle = firstLine.slice(0, 60) || 'Chat excerpt';
// Show save-to-note modal
_showSaveToNoteModal({
content,
sourceChannelId: App.currentChatId || '',
sourceMessageId: msg.id || '',
defaultTitle,
});
}
/** Modal for save-to-note with create/append options */
function _showSaveToNoteModal(opts) {
// Remove existing modal if any
document.getElementById('saveToNoteModal')?.remove();
const modal = document.createElement('div');
modal.id = 'saveToNoteModal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content save-to-note-modal">
<h3>Save to Note</h3>
<div class="save-note-mode-toggle">
<label><input type="radio" name="saveNoteMode" value="create" checked> Create new note</label>
<label><input type="radio" name="saveNoteMode" value="append"> Append to existing</label>
</div>
<div id="saveNoteCreateFields">
<div class="form-group">
<input type="text" id="saveNoteTitle" placeholder="Title" value="${esc(opts.defaultTitle)}" class="notes-title-input">
</div>
<div class="form-row">
<div class="form-group"><input type="text" id="saveNoteFolder" placeholder="Folder (e.g. /work)" class="notes-folder-input"></div>
<div class="form-group"><input type="text" id="saveNoteTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
</div>
</div>
<div id="saveNoteAppendFields" style="display:none">
<div class="form-group">
<input type="text" id="saveNoteSearch" placeholder="Search notes to append to..." class="notes-title-input" autocomplete="off">
<div id="saveNoteSearchResults" class="save-note-search-results"></div>
</div>
</div>
<div class="form-group">
<label class="text-muted" style="font-size:12px">Preview (${opts.content.length} chars)</label>
<div class="save-note-preview msg-text">${formatMessage(opts.content.slice(0, 300))}${opts.content.length > 300 ? '…' : ''}</div>
</div>
<div class="modal-actions">
<button class="btn-small" id="saveNoteCancelBtn">Cancel</button>
<button class="btn-small btn-primary" id="saveNoteConfirmBtn">Create</button>
</div>
</div>
`;
document.body.appendChild(modal);
let selectedAppendNote = null;
// Toggle create vs append
modal.querySelectorAll('input[name="saveNoteMode"]').forEach(radio => {
radio.addEventListener('change', (e) => {
const isAppend = e.target.value === 'append';
document.getElementById('saveNoteCreateFields').style.display = isAppend ? 'none' : '';
document.getElementById('saveNoteAppendFields').style.display = isAppend ? '' : 'none';
document.getElementById('saveNoteConfirmBtn').textContent = isAppend ? 'Append' : 'Create';
selectedAppendNote = null;
document.getElementById('saveNoteSearchResults').innerHTML = '';
});
});
// Append search with debounce
let _searchTimer;
document.getElementById('saveNoteSearch')?.addEventListener('input', (e) => {
clearTimeout(_searchTimer);
const q = e.target.value.trim();
selectedAppendNote = null;
_searchTimer = setTimeout(async () => {
const resultsEl = document.getElementById('saveNoteSearchResults');
if (q.length < 2) { resultsEl.innerHTML = ''; return; }
try {
const resp = await API.searchNoteTitles(q, 8);
const notes = resp.data || [];
resultsEl.innerHTML = notes.map(n =>
`<div class="save-note-result" data-id="${n.id}" data-title="${esc(n.title)}">${esc(n.title)}</div>`
).join('') || '<div class="text-muted" style="padding:6px">No results</div>';
resultsEl.querySelectorAll('.save-note-result').forEach(el => {
el.addEventListener('click', () => {
resultsEl.querySelectorAll('.save-note-result').forEach(r => r.classList.remove('selected'));
el.classList.add('selected');
selectedAppendNote = { id: el.dataset.id, title: el.dataset.title };
document.getElementById('saveNoteSearch').value = el.dataset.title;
});
});
} catch { resultsEl.innerHTML = '<div class="text-muted" style="padding:6px">Search failed</div>'; }
}, 250);
});
// Cancel
document.getElementById('saveNoteCancelBtn').addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
// Confirm
document.getElementById('saveNoteConfirmBtn').addEventListener('click', async () => {
const isAppend = modal.querySelector('input[name="saveNoteMode"]:checked')?.value === 'append';
try {
if (isAppend) {
if (!selectedAppendNote) { UI.toast('Select a note to append to', 'warning'); return; }
await API.updateNote(selectedAppendNote.id, {
content: '\n\n---\n\n' + opts.content,
mode: 'append',
});
UI.toast(`Appended to "${selectedAppendNote.title}"`, 'success');
} else {
const title = document.getElementById('saveNoteTitle').value.trim();
if (!title) { UI.toast('Title is required', 'warning'); return; }
const folder = document.getElementById('saveNoteFolder').value.trim();
const tagsStr = document.getElementById('saveNoteTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
await API.createNote(title, opts.content, folder, tags, opts.sourceChannelId, opts.sourceMessageId);
UI.toast('Note created', 'success');
}
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
modal.remove();
} catch (e) { UI.toast(e.message, 'error'); }
});
// Focus title
document.getElementById('saveNoteTitle')?.focus();
document.getElementById('saveNoteTitle')?.select();
}
// ── Notes Listeners (extracted from initListeners) ──
function _initNotesListeners() {
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
document.getElementById('notesGraphBtn')?.addEventListener('click', () => {
if (typeof openNoteGraph === 'function') openNoteGraph();
});
document.getElementById('notesTodayBtn')?.addEventListener('click', openDailyNote);
document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => {
if (_notesSelectMode) _exitSelectMode();
else _enterSelectMode();
@@ -334,7 +761,6 @@ function _initNotesListeners() {
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
document.getElementById('noteEditBtn2')?.addEventListener('click', _showNoteEditMode);
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }

View File

@@ -418,6 +418,7 @@ const UI = {
${editBtn}
${regenBtn}
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
<button class="msg-action-btn" onclick="saveMessageToNote(${index})" title="Save to note">Note</button>
</div>
</div>
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}