Changeset 0.17.3 (#78)
This commit is contained in:
86
CHANGELOG.md
86
CHANGELOG.md
@@ -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.15–4.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 h1–h3, 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
|
||||
|
||||
@@ -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 (h1–h3), 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.15–4.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
|
||||
|
||||
35
server/database/migrations/003_v0173_note_links.sql
Normal file
35
server/database/migrations/003_v0173_note_links.sql
Normal 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;
|
||||
26
server/database/migrations/sqlite/002_v0173_note_links.sql
Normal file
26
server/database/migrations/sqlite/002_v0173_note_links.sql
Normal 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;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
// =========================================
|
||||
|
||||
50
server/notelinks/extract.go
Normal file
50
server/notelinks/extract.go
Normal 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
|
||||
}
|
||||
122
server/notelinks/extract_test.go
Normal file
122
server/notelinks/extract_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// =========================================
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
179
server/store/postgres/note_link.go
Normal file
179
server/store/postgres/note_link.go
Normal 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()
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
182
server/store/sqlite/note_link.go
Normal file
182
server/store/sqlite/note_link.go
Normal 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()
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
207
src/editor/note-editor.mjs
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
203
src/editor/wikilink.mjs
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
490
src/js/note-graph.js
Normal 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 = {};
|
||||
}
|
||||
438
src/js/notes.js
438
src/js/notes.js
@@ -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(); }
|
||||
|
||||
@@ -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) : ''}
|
||||
|
||||
Reference in New Issue
Block a user