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.
|
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
|
## [0.17.2] — 2026-02-28
|
||||||
|
|
||||||
### Added
|
### 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
|
- **Channels**: Don't assume single-owner. If touching channel queries, keep
|
||||||
room for `team_id`, `type` (beyond `direct`), and multi-participant access
|
room for `team_id`, `type` (beyond `direct`), and multi-participant access
|
||||||
patterns alongside `user_id`.
|
patterns alongside `user_id`.
|
||||||
- **Notes**: Currently user-scoped. Future channel-scoped notes (attached to
|
- **Notes**: User-scoped with `[[wikilink]]` bi-directional linking (v0.17.3).
|
||||||
a conversation, not a personal notebook) need a `channel_id` FK option.
|
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"
|
- **Personas**: Already team-scoped. Don't couple to "user picks from dropdown"
|
||||||
— a workflow stage references a persona programmatically.
|
— a workflow stage references a persona programmatically.
|
||||||
- **Tool ExecutionContext**: Already carries `UserID` + `ChannelID`. Will need
|
- **Tool ExecutionContext**: Already carries `UserID` + `ChannelID`. Will need
|
||||||
@@ -153,9 +160,10 @@ server/
|
|||||||
│ ├── presets.go # Persona CRUD (all scopes)
|
│ ├── presets.go # Persona CRUD (all scopes)
|
||||||
│ ├── apiconfigs.go # User provider config CRUD (BYOK)
|
│ ├── apiconfigs.go # User provider config CRUD (BYOK)
|
||||||
│ ├── teams.go # Team management
|
│ ├── teams.go # Team management
|
||||||
│ ├── notes.go # Notes CRUD + search
|
│ ├── notes.go # Notes CRUD + search + graph + backlinks
|
||||||
│ ├── knowledge.go # Knowledge base management
|
│ ├── knowledge.go # Knowledge base management
|
||||||
│ └── ...
|
│ └── ...
|
||||||
|
├── notelinks/ # Wikilink extraction (regex → NoteLink structs)
|
||||||
├── knowledge/ # KB chunking, embedding, search
|
├── knowledge/ # KB chunking, embedding, search
|
||||||
├── providers/ # LLM provider adapters
|
├── providers/ # LLM provider adapters
|
||||||
│ ├── provider.go # Provider interface
|
│ ├── provider.go # Provider interface
|
||||||
@@ -257,7 +265,8 @@ src/
|
|||||||
│ ├── settings-handlers.js # User settings CRUD, command palette
|
│ ├── settings-handlers.js # User settings CRUD, command palette
|
||||||
│ ├── tokens.js # Input token counting + context budget
|
│ ├── tokens.js # Input token counting + context budget
|
||||||
│ ├── attachments.js # File upload, paste-to-file, lightbox
|
│ ├── 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
|
│ ├── knowledge.js # Knowledge base UI
|
||||||
│ └── __tests__/ # Node.js test suite (node --test)
|
│ └── __tests__/ # Node.js test suite (node --test)
|
||||||
├── editor/ # CM6 source (compiled at build time)
|
├── editor/ # CM6 source (compiled at build time)
|
||||||
@@ -267,7 +276,9 @@ src/
|
|||||||
│ ├── index.mjs # Bundle entrypoint (window.CM)
|
│ ├── index.mjs # Bundle entrypoint (window.CM)
|
||||||
│ ├── chat-input.mjs # Chat input factory (markdown, code blocks)
|
│ ├── chat-input.mjs # Chat input factory (markdown, code blocks)
|
||||||
│ ├── code-editor.mjs # Code editor factory (syntax, keybindings)
|
│ ├── 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)
|
└── vendor/ # Vendored libraries (local + CDN fallback)
|
||||||
├── marked/ # Markdown renderer
|
├── marked/ # Markdown renderer
|
||||||
├── purify/ # DOMPurify (XSS protection)
|
├── purify/ # DOMPurify (XSS protection)
|
||||||
@@ -279,12 +290,14 @@ src/
|
|||||||
|
|
||||||
### CM6 Integration
|
### 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.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.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.
|
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.
|
**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
|
**Version:** v0.17.3
|
||||||
**Status:** Draft
|
**Status:** Draft
|
||||||
**Scope:** Upgrade the notes editor from a plain `<textarea>` to a CM6-powered
|
**Scope:** Upgrade the notes editor from a plain `<textarea>` to a CM6-powered
|
||||||
markdown editor with live preview, `[[wikilink]]` resolution, and a backlinks
|
markdown editor with live preview, `[[wikilink]]` resolution, a backlinks
|
||||||
system.
|
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
|
**Depends on:** v0.17.2 (CodeMirror 6 bundle provides `CM.codeEditor()` and
|
||||||
the markdown language mode).
|
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
|
(v0.14.0 KB infrastructure), so link autocomplete can be smarter than
|
||||||
filename matching.
|
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
|
## Architecture
|
||||||
@@ -50,6 +57,8 @@ CM.noteEditor(target, {
|
|||||||
highlighted — the document is still markdown but *looks* formatted
|
highlighted — the document is still markdown but *looks* formatted
|
||||||
- `[[` triggers autocomplete dropdown of existing notes (fuzzy search)
|
- `[[` triggers autocomplete dropdown of existing notes (fuzzy search)
|
||||||
- `[[Note Title]]` tokens rendered as clickable chips (CM6 `Decoration.widget`)
|
- `[[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)
|
- Line numbers off (it's a note, not code)
|
||||||
- Spell check enabled (`EditorView.contentAttributes: { spellcheck: 'true' }`)
|
- Spell check enabled (`EditorView.contentAttributes: { spellcheck: 'true' }`)
|
||||||
- Vim/Emacs keybindings respected (user preference from v0.17.2)
|
- 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]] → links to note titled "Note Title"
|
||||||
[[Note Title|display text]] → shows "display text", links to "Note Title"
|
[[Note Title|display text]] → shows "display text", links to "Note Title"
|
||||||
|
![[Note Title]] → embeds target note content inline (transclusion)
|
||||||
```
|
```
|
||||||
|
|
||||||
Resolution order:
|
Resolution order:
|
||||||
@@ -105,6 +115,7 @@ CREATE TABLE IF NOT EXISTS note_links (
|
|||||||
target_note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
|
target_note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
target_title TEXT NOT NULL, -- raw [[title]] text, for unresolved links
|
target_title TEXT NOT NULL, -- raw [[title]] text, for unresolved links
|
||||||
display_text TEXT, -- optional |alias
|
display_text TEXT, -- optional |alias
|
||||||
|
is_transclusion BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
PRIMARY KEY (source_note_id, target_title)
|
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_note_id TEXT REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
target_title TEXT NOT NULL,
|
target_title TEXT NOT NULL,
|
||||||
display_text TEXT,
|
display_text TEXT,
|
||||||
|
is_transclusion INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
PRIMARY KEY (source_note_id, target_title)
|
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).
|
the same title once (last one wins if duplicated).
|
||||||
- `target_title` is always stored for human readability and re-resolution
|
- `target_title` is always stored for human readability and re-resolution
|
||||||
after renames.
|
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)
|
### Link Extraction (on save)
|
||||||
|
|
||||||
When a note is saved, the backend:
|
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`
|
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`
|
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
|
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"
|
This runs once on note creation — cheap and ensures links "light up"
|
||||||
retroactively.
|
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
|
## API Changes
|
||||||
@@ -183,7 +225,9 @@ retroactively.
|
|||||||
No API signature change; link extraction is a server-side side effect.
|
No API signature change; link extraction is a server-side side effect.
|
||||||
|
|
||||||
**`POST /api/v1/notes`** — after creating a note, extract links AND resolve
|
**`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
|
### 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
|
The semantic search endpoint (`GET /api/v1/notes/search`) remains available
|
||||||
for deeper searches but is overkill for autocomplete.
|
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
|
## 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
|
clickable links. A small marked.js extension or post-render pass
|
||||||
converts `[[...]]` to `<a>` tags with `onclick` handlers.
|
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
|
## Access Control
|
||||||
@@ -318,6 +807,7 @@ converts `[[...]]` to `<a>` tags with `onclick` handlers.
|
|||||||
- Create links to notes they own (their own notes)
|
- Create links to notes they own (their own notes)
|
||||||
- See backlinks from notes they own
|
- See backlinks from notes they own
|
||||||
- Autocomplete searches their own notes
|
- Autocomplete searches their own notes
|
||||||
|
- View their own graph
|
||||||
|
|
||||||
Team-scoped notes (via `team_id` on the notes table) follow the same
|
Team-scoped notes (via `team_id` on the notes table) follow the same
|
||||||
grant model as other team resources. When team notes ship (potentially
|
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 returns notes that link to the given note.
|
||||||
Backlinks(ctx context.Context, noteID string) ([]NoteLinkResult, error)
|
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 {
|
type NoteLink struct {
|
||||||
TargetNoteID *string // nil for unresolved
|
TargetNoteID *string // nil for unresolved
|
||||||
TargetTitle string
|
TargetTitle string
|
||||||
DisplayText string
|
DisplayText string
|
||||||
|
IsTransclusion bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type NoteLinkResult struct {
|
type NoteLinkResult struct {
|
||||||
@@ -359,6 +853,33 @@ type NoteLinkResult struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
DisplayText string
|
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
|
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
|
### Link Extraction Helper
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// ExtractWikilinks parses [[Title]] and [[Title|Display]] from markdown content.
|
// ExtractWikilinks parses [[Title]], [[Title|Display]], and ![[Title]] from markdown.
|
||||||
func ExtractWikilinks(content string) []NoteLink {
|
func ExtractWikilinks(content string) []NoteLink {
|
||||||
re := regexp.MustCompile(`\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]`)
|
re := regexp.MustCompile(`(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]`)
|
||||||
matches := re.FindAllStringSubmatch(content, -1)
|
matches := re.FindAllStringSubmatch(content, -1)
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var links []NoteLink
|
var links []NoteLink
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
title := strings.TrimSpace(m[1])
|
title := strings.TrimSpace(m[2])
|
||||||
if seen[strings.ToLower(title)] { continue }
|
key := strings.ToLower(title)
|
||||||
seen[strings.ToLower(title)] = true
|
if seen[key] { continue }
|
||||||
link := NoteLink{TargetTitle: title}
|
seen[key] = true
|
||||||
if len(m) > 2 && m[2] != "" {
|
link := NoteLink{
|
||||||
link.DisplayText = strings.TrimSpace(m[2])
|
TargetTitle: title,
|
||||||
|
IsTransclusion: m[1] == "!",
|
||||||
|
}
|
||||||
|
if len(m) > 3 && m[3] != "" {
|
||||||
|
link.DisplayText = strings.TrimSpace(m[3])
|
||||||
}
|
}
|
||||||
links = append(links, link)
|
links = append(links, link)
|
||||||
}
|
}
|
||||||
@@ -394,12 +919,16 @@ string parsing with no DB dependency.
|
|||||||
|
|
||||||
## Migration
|
## Migration
|
||||||
|
|
||||||
**Migration 002** (both Postgres and SQLite):
|
**Migration file** (both Postgres and SQLite):
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- 002_note_links.sql
|
-- note_links table
|
||||||
CREATE TABLE IF NOT EXISTS note_links ( ... );
|
CREATE TABLE IF NOT EXISTS note_links ( ... );
|
||||||
CREATE INDEX ...;
|
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,
|
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
|
## Implementation Checklist
|
||||||
|
|
||||||
### Phase 1: Backend (links infrastructure)
|
### 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
|
- [ ] `NoteLinkStore` interface + Postgres/SQLite implementations
|
||||||
- [ ] `ExtractWikilinks()` helper with tests
|
- [ ] `ExtractWikilinks()` helper with tests
|
||||||
- [ ] Link extraction on note create/update in handlers
|
- [ ] Link extraction on note create/update in handlers
|
||||||
- [ ] Dangling link resolution on note create
|
- [ ] Dangling link resolution on note create
|
||||||
- [ ] `GET /api/v1/notes/:id/backlinks` endpoint
|
- [ ] `GET /api/v1/notes/:id/backlinks` endpoint
|
||||||
- [ ] `GET /api/v1/notes/search-titles?q=` 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`
|
- [ ] API client additions in `api.js`
|
||||||
|
|
||||||
### Phase 2: Frontend (CM6 note editor)
|
### Phase 2: Frontend (CM6 note editor)
|
||||||
@@ -428,24 +984,78 @@ with the new editor.
|
|||||||
- [ ] `saveNote()` reads from CM6 `.getValue()`
|
- [ ] `saveNote()` reads from CM6 `.getValue()`
|
||||||
- [ ] Graceful fallback if CM6 unavailable
|
- [ ] Graceful fallback if CM6 unavailable
|
||||||
- [ ] Backlinks panel UI below editor
|
- [ ] 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)
|
- [ ] Wikilink chips styled to match app theme (accent color, hover state)
|
||||||
- [ ] Unresolved links styled distinctly (red/dashed, "click to create")
|
- [ ] Unresolved links styled distinctly (red/dashed, "click to create")
|
||||||
|
- [ ] Transclusion embeds: border, header bar, expand/collapse
|
||||||
- [ ] Autocomplete dropdown styled (note title + folder path hint)
|
- [ ] Autocomplete dropdown styled (note title + folder path hint)
|
||||||
- [ ] Keyboard navigation in autocomplete (↑/↓, Enter to select, Esc to dismiss)
|
- [ ] Keyboard navigation in autocomplete (↑/↓, Enter, Esc)
|
||||||
- [ ] Mobile/touch testing for link taps
|
- [ ] 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 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)
|
## 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
|
- **Link suggestions**: "notes that might be related" via embedding similarity
|
||||||
(reuse KB infrastructure)
|
(reuse KB infrastructure)
|
||||||
- **Transclusion**: `![[Note Title]]` embeds the target note's content inline
|
|
||||||
- **Tag linking**: `#tag` syntax as an alternative navigation path
|
- **Tag linking**: `#tag` syntax as an alternative navigation path
|
||||||
- **Team note links**: cross-user linking when team/channel-scoped notes ship
|
- **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.1 SQLite Backend ✅ v0.17.2 CodeMirror 6 ✅
|
||||||
|
v0.17.3 Notes Graph + Wikilinks
|
||||||
(dual DB) (editor bundle, chat
|
(dual DB) (editor bundle, chat
|
||||||
│ input, ext editor)
|
│ input, ext editor)
|
||||||
└───────┬──────────────┘
|
└───────┬──────────────┘
|
||||||
│
|
│
|
||||||
v0.18.0 Memory (user + persona scopes, review pipeline)
|
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.19.0 Projects / Workspaces + Notifications
|
||||||
│
|
│
|
||||||
v0.20.0 @mention Routing + Multi-model
|
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)
|
## v0.18.0 — Memory (User + Persona Scopes)
|
||||||
|
|
||||||
Long-term memory across conversations, with scope-aware isolation.
|
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
|
## v0.19.0 — Projects / Workspaces + Notifications
|
||||||
|
|
||||||
Organizational containers that group related conversations, KBs, and
|
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/database"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/notelinks"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ type createNoteRequest struct {
|
|||||||
FolderPath string `json:"folder_path"`
|
FolderPath string `json:"folder_path"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
SourceChannelID string `json:"source_channel_id"`
|
SourceChannelID string `json:"source_channel_id"`
|
||||||
|
SourceMessageID string `json:"source_message_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateNoteRequest struct {
|
type updateNoteRequest struct {
|
||||||
@@ -38,6 +40,7 @@ type noteResponse struct {
|
|||||||
FolderPath string `json:"folder_path"`
|
FolderPath string `json:"folder_path"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
SourceChannelID *string `json:"source_channel_id,omitempty"`
|
SourceChannelID *string `json:"source_channel_id,omitempty"`
|
||||||
|
SourceMessageID *string `json:"source_message_id,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -85,6 +88,7 @@ func toNoteResponse(n *models.Note) noteResponse {
|
|||||||
FolderPath: n.FolderPath,
|
FolderPath: n.FolderPath,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
SourceChannelID: n.SourceChannelID,
|
SourceChannelID: n.SourceChannelID,
|
||||||
|
SourceMessageID: n.SourceMessageID,
|
||||||
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
UpdatedAt: n.UpdatedAt.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 != "" {
|
if req.SourceChannelID != "" {
|
||||||
sourceChannelID = &req.SourceChannelID
|
sourceChannelID = &req.SourceChannelID
|
||||||
}
|
}
|
||||||
|
var sourceMessageID *string
|
||||||
|
if req.SourceMessageID != "" {
|
||||||
|
sourceMessageID = &req.SourceMessageID
|
||||||
|
}
|
||||||
|
|
||||||
note := &models.Note{
|
note := &models.Note{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -140,6 +148,7 @@ func (h *NoteHandler) Create(c *gin.Context) {
|
|||||||
FolderPath: folder,
|
FolderPath: folder,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
SourceChannelID: sourceChannelID,
|
SourceChannelID: sourceChannelID,
|
||||||
|
SourceMessageID: sourceMessageID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.stores.Notes.Create(c.Request.Context(), note); err != nil {
|
if err := h.stores.Notes.Create(c.Request.Context(), note); err != nil {
|
||||||
@@ -147,6 +156,14 @@ func (h *NoteHandler) Create(c *gin.Context) {
|
|||||||
return
|
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))
|
c.JSON(http.StatusCreated, toNoteResponse(note))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +252,11 @@ func (h *NoteHandler) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-extract links if content changed
|
||||||
|
if req.Content != nil {
|
||||||
|
h.extractAndStoreLinks(c, noteID, updated.Content, userID)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, toNoteResponse(updated))
|
c.JSON(http.StatusOK, toNoteResponse(updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,3 +522,122 @@ func normalizeFolderPath(p string) string {
|
|||||||
}
|
}
|
||||||
return p
|
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.GET("/notes", notes.List)
|
||||||
protected.POST("/notes", notes.Create)
|
protected.POST("/notes", notes.Create)
|
||||||
protected.GET("/notes/search", notes.Search)
|
protected.GET("/notes/search", notes.Search)
|
||||||
|
protected.GET("/notes/search-titles", notes.SearchTitles)
|
||||||
protected.GET("/notes/folders", notes.ListFolders)
|
protected.GET("/notes/folders", notes.ListFolders)
|
||||||
|
protected.GET("/notes/graph", notes.Graph)
|
||||||
protected.POST("/notes/bulk-delete", notes.BulkDelete)
|
protected.POST("/notes/bulk-delete", notes.BulkDelete)
|
||||||
protected.GET("/notes/:id", notes.Get)
|
protected.GET("/notes/:id", notes.Get)
|
||||||
protected.PUT("/notes/:id", notes.Update)
|
protected.PUT("/notes/:id", notes.Update)
|
||||||
protected.DELETE("/notes/:id", notes.Delete)
|
protected.DELETE("/notes/:id", notes.Delete)
|
||||||
|
protected.GET("/notes/:id/backlinks", notes.Backlinks)
|
||||||
|
|
||||||
// Attachments (file upload/download)
|
// Attachments (file upload/download)
|
||||||
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
||||||
|
|||||||
@@ -389,9 +389,58 @@ type Note struct {
|
|||||||
Tags []string `json:"tags,omitempty" db:"tags"`
|
Tags []string `json:"tags,omitempty" db:"tags"`
|
||||||
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
|
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
|
||||||
SourceChannelID *string `json:"source_channel_id,omitempty" db:"source_channel_id"`
|
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"`
|
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
|
// 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
|
Messages MessageStore
|
||||||
Audit AuditStore
|
Audit AuditStore
|
||||||
Notes NoteStore
|
Notes NoteStore
|
||||||
|
NoteLinks NoteLinkStore
|
||||||
GlobalConfig GlobalConfigStore
|
GlobalConfig GlobalConfigStore
|
||||||
Usage UsageStore
|
Usage UsageStore
|
||||||
Pricing PricingStore
|
Pricing PricingStore
|
||||||
@@ -269,6 +270,7 @@ type NoteStore interface {
|
|||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
ListForUser(ctx context.Context, userID string, opts NoteListOptions) ([]models.Note, int, 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)
|
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)
|
BulkDelete(ctx context.Context, ids []string, userID string) (int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +281,25 @@ type NoteListOptions struct {
|
|||||||
TeamID string
|
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
|
// GLOBAL CONFIG STORE
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -19,31 +19,33 @@ func NewNoteStore() *NoteStore { return &NoteStore{} }
|
|||||||
|
|
||||||
func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
|
func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
|
||||||
return DB.QueryRowContext(ctx, `
|
return DB.QueryRowContext(ctx, `
|
||||||
INSERT INTO notes (user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id)
|
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)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
RETURNING id, created_at, updated_at`,
|
RETURNING id, created_at, updated_at`,
|
||||||
n.UserID, n.Title, n.Content, n.FolderPath,
|
n.UserID, n.Title, n.Content, n.FolderPath,
|
||||||
pq.Array(n.Tags), ToJSON(n.Metadata),
|
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)
|
).Scan(&n.ID, &n.CreatedAt, &n.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) {
|
func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) {
|
||||||
var n models.Note
|
var n models.Note
|
||||||
var sourceChannelID, teamID sql.NullString
|
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
||||||
var metadataJSON []byte
|
var metadataJSON []byte
|
||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT id, user_id, title, content, folder_path, tags, metadata,
|
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(
|
FROM notes WHERE id = $1`, id).Scan(
|
||||||
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
||||||
pq.Array(&n.Tags), &metadataJSON,
|
pq.Array(&n.Tags), &metadataJSON,
|
||||||
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt,
|
&sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
||||||
|
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
||||||
n.TeamID = NullableStringPtr(teamID)
|
n.TeamID = NullableStringPtr(teamID)
|
||||||
json.Unmarshal(metadataJSON, &n.Metadata)
|
json.Unmarshal(metadataJSON, &n.Metadata)
|
||||||
return &n, nil
|
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) {
|
func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.NoteListOptions) ([]models.Note, int, error) {
|
||||||
b := NewSelect(
|
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",
|
"notes",
|
||||||
).Where("user_id = ?", userID)
|
).Where("user_id = ?", userID)
|
||||||
|
|
||||||
@@ -111,15 +113,16 @@ func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.N
|
|||||||
var result []models.Note
|
var result []models.Note
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n models.Note
|
var n models.Note
|
||||||
var sourceChannelID, teamID sql.NullString
|
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
||||||
var metadataJSON []byte
|
var metadataJSON []byte
|
||||||
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
||||||
pq.Array(&n.Tags), &metadataJSON,
|
pq.Array(&n.Tags), &metadataJSON,
|
||||||
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt)
|
&sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
||||||
|
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
||||||
n.TeamID = NullableStringPtr(teamID)
|
n.TeamID = NullableStringPtr(teamID)
|
||||||
json.Unmarshal(metadataJSON, &n.Metadata)
|
json.Unmarshal(metadataJSON, &n.Metadata)
|
||||||
result = append(result, n)
|
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), " & ")
|
tsQuery := strings.Join(strings.Fields(query), " & ")
|
||||||
|
|
||||||
b := NewSelect(
|
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",
|
"notes",
|
||||||
).Where("user_id = ?", userID).Where("search_vector @@ to_tsquery('english', ?)", tsQuery)
|
).Where("user_id = ?", userID).Where("search_vector @@ to_tsquery('english', ?)", tsQuery)
|
||||||
b.OrderBy("ts_rank(search_vector, to_tsquery('english', '"+tsQuery+"'))", "DESC")
|
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
|
var result []models.Note
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n models.Note
|
var n models.Note
|
||||||
var sourceChannelID, teamID sql.NullString
|
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
||||||
var metadataJSON []byte
|
var metadataJSON []byte
|
||||||
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
||||||
pq.Array(&n.Tags), &metadataJSON,
|
pq.Array(&n.Tags), &metadataJSON,
|
||||||
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt)
|
&sourceChannelID, &sourceMessageID, &teamID, &n.CreatedAt, &n.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
||||||
|
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
||||||
n.TeamID = NullableStringPtr(teamID)
|
n.TeamID = NullableStringPtr(teamID)
|
||||||
json.Unmarshal(metadataJSON, &n.Metadata)
|
json.Unmarshal(metadataJSON, &n.Metadata)
|
||||||
result = append(result, n)
|
result = append(result, n)
|
||||||
@@ -189,3 +193,29 @@ func (s *NoteStore) BulkDelete(ctx context.Context, ids []string, userID string)
|
|||||||
n, _ := result.RowsAffected()
|
n, _ := result.RowsAffected()
|
||||||
return int(n), nil
|
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(),
|
Messages: NewMessageStore(),
|
||||||
Audit: NewAuditStore(),
|
Audit: NewAuditStore(),
|
||||||
Notes: NewNoteStore(),
|
Notes: NewNoteStore(),
|
||||||
|
NoteLinks: NewNoteLinkStore(),
|
||||||
GlobalConfig: NewGlobalConfigStore(),
|
GlobalConfig: NewGlobalConfigStore(),
|
||||||
Usage: NewUsageStore(),
|
Usage: NewUsageStore(),
|
||||||
Pricing: NewPricingStore(),
|
Pricing: NewPricingStore(),
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
|
|||||||
n.CreatedAt = now
|
n.CreatedAt = now
|
||||||
n.UpdatedAt = now
|
n.UpdatedAt = now
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, 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)
|
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 (?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
n.ID, n.UserID, n.Title, n.Content, n.FolderPath,
|
n.ID, n.UserID, n.Title, n.Content, n.FolderPath,
|
||||||
ArrayToJSON(n.Tags), ToJSON(n.Metadata),
|
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),
|
now.Format(timeFmt), now.Format(timeFmt),
|
||||||
)
|
)
|
||||||
return err
|
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) {
|
func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) {
|
||||||
var n models.Note
|
var n models.Note
|
||||||
var sourceChannelID, teamID sql.NullString
|
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
||||||
var tagsJSON, metadataJSON string
|
var tagsJSON, metadataJSON string
|
||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT id, user_id, title, content, folder_path, tags, metadata,
|
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(
|
FROM notes WHERE id = ?`, id).Scan(
|
||||||
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
||||||
&tagsJSON, &metadataJSON,
|
&tagsJSON, &metadataJSON,
|
||||||
&sourceChannelID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt),
|
&sourceChannelID, &sourceMessageID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
n.Tags = ScanArray(tagsJSON)
|
n.Tags = ScanArray(tagsJSON)
|
||||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
||||||
|
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
||||||
n.TeamID = NullableStringPtr(teamID)
|
n.TeamID = NullableStringPtr(teamID)
|
||||||
json.Unmarshal([]byte(metadataJSON), &n.Metadata)
|
json.Unmarshal([]byte(metadataJSON), &n.Metadata)
|
||||||
return &n, nil
|
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) {
|
func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.NoteListOptions) ([]models.Note, int, error) {
|
||||||
b := NewSelect(
|
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",
|
"notes",
|
||||||
).Where("user_id = ?", userID)
|
).Where("user_id = ?", userID)
|
||||||
|
|
||||||
@@ -126,7 +128,7 @@ func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store
|
|||||||
}
|
}
|
||||||
|
|
||||||
b := NewSelect(
|
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",
|
"notes",
|
||||||
).Where("user_id = ?", userID)
|
).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)
|
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) {
|
func (s *NoteStore) BulkDelete(ctx context.Context, ids []string, userID string) (int, error) {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
@@ -181,16 +209,17 @@ func (s *NoteStore) scanNotes(rows *sql.Rows, total int) ([]models.Note, int, er
|
|||||||
var result []models.Note
|
var result []models.Note
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n models.Note
|
var n models.Note
|
||||||
var sourceChannelID, teamID sql.NullString
|
var sourceChannelID, sourceMessageID, teamID sql.NullString
|
||||||
var tagsJSON, metadataJSON string
|
var tagsJSON, metadataJSON string
|
||||||
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
|
||||||
&tagsJSON, &metadataJSON,
|
&tagsJSON, &metadataJSON,
|
||||||
&sourceChannelID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt))
|
&sourceChannelID, &sourceMessageID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
n.Tags = ScanArray(tagsJSON)
|
n.Tags = ScanArray(tagsJSON)
|
||||||
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
n.SourceChannelID = NullableStringPtr(sourceChannelID)
|
||||||
|
n.SourceMessageID = NullableStringPtr(sourceMessageID)
|
||||||
n.TeamID = NullableStringPtr(teamID)
|
n.TeamID = NullableStringPtr(teamID)
|
||||||
json.Unmarshal([]byte(metadataJSON), &n.Metadata)
|
json.Unmarshal([]byte(metadataJSON), &n.Metadata)
|
||||||
result = append(result, n)
|
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(),
|
Messages: NewMessageStore(),
|
||||||
Audit: NewAuditStore(),
|
Audit: NewAuditStore(),
|
||||||
Notes: NewNoteStore(),
|
Notes: NewNoteStore(),
|
||||||
|
NoteLinks: NewNoteLinkStore(),
|
||||||
GlobalConfig: NewGlobalConfigStore(),
|
GlobalConfig: NewGlobalConfigStore(),
|
||||||
Usage: NewUsageStore(),
|
Usage: NewUsageStore(),
|
||||||
Pricing: NewPricingStore(),
|
Pricing: NewPricingStore(),
|
||||||
|
|||||||
@@ -1870,6 +1870,128 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
display: flex; gap: 8px; margin-top: 12px;
|
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 ──────────────────────── */
|
||||||
|
|
||||||
.sidebar-search {
|
.sidebar-search {
|
||||||
|
|||||||
@@ -30,12 +30,13 @@ import { emacs } from '@replit/codemirror-emacs';
|
|||||||
|
|
||||||
import { codeEditor } from './code-editor.mjs';
|
import { codeEditor } from './code-editor.mjs';
|
||||||
import { chatInput } from './chat-input.mjs';
|
import { chatInput } from './chat-input.mjs';
|
||||||
|
import { noteEditor } from './note-editor.mjs';
|
||||||
|
|
||||||
// ── Expose on window ────────────────────────
|
// ── Expose on window ────────────────────────
|
||||||
|
|
||||||
window.CM = {
|
window.CM = {
|
||||||
// Version (injected by the build script or matched to app version)
|
// 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)
|
// Low-level access (for advanced use / debug console)
|
||||||
EditorView,
|
EditorView,
|
||||||
@@ -49,6 +50,10 @@ window.CM = {
|
|||||||
// CM.chatInput(container, { placeholder, onSubmit, onChange, maxHeight, darkMode })
|
// CM.chatInput(container, { placeholder, onSubmit, onChange, maxHeight, darkMode })
|
||||||
chatInput,
|
chatInput,
|
||||||
|
|
||||||
|
// Factory: rich markdown note editor with wikilinks
|
||||||
|
// CM.noteEditor(container, { value, darkMode, onChange, onLink, linkCompleter })
|
||||||
|
noteEditor,
|
||||||
|
|
||||||
// Bundled language modes (for reference / dynamic use)
|
// Bundled language modes (for reference / dynamic use)
|
||||||
languages: {
|
languages: {
|
||||||
javascript, json, markdown, sql, html, css, yaml, go, python, rust,
|
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)',
|
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>
|
||||||
<div class="notes-actions-bar">
|
<div class="notes-actions-bar">
|
||||||
<button class="btn-small" id="notesSelectModeBtn">Select</button>
|
<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>
|
<button class="btn-small btn-primary" id="notesNewBtn">+ New Note</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="notes-selection-bar" id="notesSelectionBar" style="display:none">
|
<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 class="notes-empty">No notes yet. Create one or ask the AI to save a note.</div>
|
||||||
</div>
|
</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) -->
|
<!-- Editor view (hidden by default) -->
|
||||||
<div id="notesEditorView" style="display:none">
|
<div id="notesEditorView" style="display:none">
|
||||||
<div class="notes-editor">
|
<div class="notes-editor">
|
||||||
@@ -265,9 +280,16 @@
|
|||||||
<h3 class="note-read-title" id="noteReadTitle"></h3>
|
<h3 class="note-read-title" id="noteReadTitle"></h3>
|
||||||
<div class="note-read-meta" id="noteReadMeta"></div>
|
<div class="note-read-meta" id="noteReadMeta"></div>
|
||||||
<div class="note-read-content msg-text" id="noteReadContent"></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">
|
<div class="notes-editor-actions">
|
||||||
<button class="btn-small" id="noteCopyBtn" onclick="copyNoteContent()">Copy</button>
|
<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>
|
<button class="btn-small btn-danger" id="noteDeleteBtn2" style="display:none">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</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="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 class="form-group"><input type="text" id="noteEditorTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
|
||||||
</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">
|
<div class="notes-editor-actions">
|
||||||
<button class="btn-small btn-primary" id="noteSaveBtn">Save</button>
|
<button class="btn-small btn-primary" id="noteSaveBtn">Save</button>
|
||||||
<button class="btn-small" id="noteCancelEditBtn" style="display:none">Cancel</button>
|
<button class="btn-small" id="noteCancelEditBtn" style="display:none">Cancel</button>
|
||||||
@@ -997,6 +1019,49 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Command Palette (Ctrl+K) -->
|
<!-- 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-overlay" id="cmdPalette">
|
||||||
<div class="cmd-palette">
|
<div class="cmd-palette">
|
||||||
<div class="cmd-input-wrap">
|
<div class="cmd-input-wrap">
|
||||||
@@ -1031,6 +1096,7 @@
|
|||||||
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>
|
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/tokens.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/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/attachments.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/tools-toggle.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>
|
<script src="js/knowledge-ui.js?v=%%APP_VERSION%%"></script>
|
||||||
|
|||||||
@@ -489,10 +489,12 @@ const API = {
|
|||||||
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
|
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
|
||||||
return this._get(url);
|
return this._get(url);
|
||||||
},
|
},
|
||||||
createNote(title, content, folderPath, tags) {
|
createNote(title, content, folderPath, tags, sourceChannelId, sourceMessageId) {
|
||||||
const body = { title, content };
|
const body = { title, content };
|
||||||
if (folderPath) body.folder_path = folderPath;
|
if (folderPath) body.folder_path = folderPath;
|
||||||
if (tags?.length) body.tags = tags;
|
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);
|
return this._post('/api/v1/notes', body);
|
||||||
},
|
},
|
||||||
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
|
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
|
||||||
@@ -500,7 +502,10 @@ const API = {
|
|||||||
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
|
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
|
||||||
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
|
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}`); },
|
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'); },
|
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 ─────────────────────────
|
// ── 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 _notesSelectMode = false;
|
||||||
var _selectedNoteIds = new Set();
|
var _selectedNoteIds = new Set();
|
||||||
var _notesSort = 'updated_desc';
|
var _notesSort = 'updated_desc';
|
||||||
|
var _noteEditor = null; // CM6 noteEditor instance
|
||||||
|
|
||||||
// ── Notes ─────────────────────────────────────
|
// ── Notes ─────────────────────────────────────
|
||||||
|
|
||||||
@@ -172,7 +173,13 @@ async function loadNoteFolders() {
|
|||||||
function showNotesList() {
|
function showNotesList() {
|
||||||
document.getElementById('notesListView').style.display = '';
|
document.getElementById('notesListView').style.display = '';
|
||||||
document.getElementById('notesEditorView').style.display = 'none';
|
document.getElementById('notesEditorView').style.display = 'none';
|
||||||
|
const graphView = document.getElementById('notesGraphView');
|
||||||
|
if (graphView) graphView.style.display = 'none';
|
||||||
_editingNoteId = null;
|
_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
|
var _currentNote = null; // cached note for read mode
|
||||||
@@ -211,14 +218,83 @@ function _populateEditFields(note) {
|
|||||||
document.getElementById('noteEditorTitle').value = note.title || '';
|
document.getElementById('noteEditorTitle').value = note.title || '';
|
||||||
document.getElementById('noteEditorFolder').value = note.folder_path || '';
|
document.getElementById('noteEditorFolder').value = note.folder_path || '';
|
||||||
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
|
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
|
||||||
document.getElementById('noteEditorContent').value = note.content || '';
|
_setNoteEditorContent(note.content || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function _clearEditFields() {
|
function _clearEditFields() {
|
||||||
document.getElementById('noteEditorTitle').value = '';
|
document.getElementById('noteEditorTitle').value = '';
|
||||||
document.getElementById('noteEditorFolder').value = '';
|
document.getElementById('noteEditorFolder').value = '';
|
||||||
document.getElementById('noteEditorTags').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() {
|
function _showNoteReadMode() {
|
||||||
@@ -239,6 +315,9 @@ function _showNoteReadMode() {
|
|||||||
noteReadEl.innerHTML = formatMessage(n.content || '');
|
noteReadEl.innerHTML = formatMessage(n.content || '');
|
||||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
|
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
|
||||||
|
|
||||||
|
// Render [[wikilinks]] as clickable chips in read mode
|
||||||
|
_renderWikilinksInReadMode(noteReadEl);
|
||||||
|
|
||||||
// Show/hide delete
|
// Show/hide delete
|
||||||
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
|
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
|
||||||
|
|
||||||
@@ -247,6 +326,12 @@ function _showNoteReadMode() {
|
|||||||
document.getElementById('noteEditMode').style.display = 'none';
|
document.getElementById('noteEditMode').style.display = 'none';
|
||||||
document.getElementById('noteEditBtn').style.display = '';
|
document.getElementById('noteEditBtn').style.display = '';
|
||||||
document.getElementById('notePreviewBtn').style.display = 'none';
|
document.getElementById('notePreviewBtn').style.display = 'none';
|
||||||
|
|
||||||
|
// Load backlinks
|
||||||
|
if (_editingNoteId) _loadBacklinks(_editingNoteId);
|
||||||
|
|
||||||
|
// Show daily note navigation if applicable
|
||||||
|
_updateDailyNav(_currentNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _showNoteEditMode() {
|
function _showNoteEditMode() {
|
||||||
@@ -259,9 +344,9 @@ function _showNoteEditMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _showNotePreview() {
|
function _showNotePreview() {
|
||||||
// Live preview from textarea without saving
|
// Live preview from editor without saving
|
||||||
const title = document.getElementById('noteEditorTitle').value.trim();
|
const title = document.getElementById('noteEditorTitle').value.trim();
|
||||||
const content = document.getElementById('noteEditorContent').value;
|
const content = _getNoteEditorContent();
|
||||||
const folderVal = document.getElementById('noteEditorFolder').value.trim();
|
const folderVal = document.getElementById('noteEditorFolder').value.trim();
|
||||||
const tagsStr = document.getElementById('noteEditorTags').value.trim();
|
const tagsStr = document.getElementById('noteEditorTags').value.trim();
|
||||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
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('noteReadMeta').innerHTML = parts.join(' ');
|
||||||
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
|
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
|
||||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(document.getElementById('noteReadContent'));
|
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(document.getElementById('noteReadContent'));
|
||||||
|
_renderWikilinksInReadMode(document.getElementById('noteReadContent'));
|
||||||
|
|
||||||
document.getElementById('noteReadMode').style.display = '';
|
document.getElementById('noteReadMode').style.display = '';
|
||||||
document.getElementById('noteEditMode').style.display = 'none';
|
document.getElementById('noteEditMode').style.display = 'none';
|
||||||
@@ -284,7 +370,7 @@ function _showNotePreview() {
|
|||||||
|
|
||||||
async function saveNote() {
|
async function saveNote() {
|
||||||
const title = document.getElementById('noteEditorTitle').value.trim();
|
const title = document.getElementById('noteEditorTitle').value.trim();
|
||||||
const content = document.getElementById('noteEditorContent').value;
|
const content = _getNoteEditorContent();
|
||||||
const folder = document.getElementById('noteEditorFolder').value.trim();
|
const folder = document.getElementById('noteEditorFolder').value.trim();
|
||||||
const tagsStr = document.getElementById('noteEditorTags').value.trim();
|
const tagsStr = document.getElementById('noteEditorTags').value.trim();
|
||||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||||
@@ -305,6 +391,8 @@ async function saveNote() {
|
|||||||
UI.toast('Note created', 'success');
|
UI.toast('Note created', 'success');
|
||||||
_showNoteReadMode();
|
_showNoteReadMode();
|
||||||
}
|
}
|
||||||
|
// Invalidate graph cache
|
||||||
|
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
|
||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,17 +402,356 @@ async function deleteNote() {
|
|||||||
try {
|
try {
|
||||||
await API.deleteNote(_editingNoteId);
|
await API.deleteNote(_editingNoteId);
|
||||||
UI.toast('Note deleted', 'success');
|
UI.toast('Note deleted', 'success');
|
||||||
|
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
|
||||||
showNotesList();
|
showNotesList();
|
||||||
await loadNotesList();
|
await loadNotesList();
|
||||||
await loadNoteFolders();
|
await loadNoteFolders();
|
||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
} 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) ──
|
// ── Notes Listeners (extracted from initListeners) ──
|
||||||
|
|
||||||
function _initNotesListeners() {
|
function _initNotesListeners() {
|
||||||
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
|
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
|
||||||
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
|
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', () => {
|
document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => {
|
||||||
if (_notesSelectMode) _exitSelectMode();
|
if (_notesSelectMode) _exitSelectMode();
|
||||||
else _enterSelectMode();
|
else _enterSelectMode();
|
||||||
@@ -334,7 +761,6 @@ function _initNotesListeners() {
|
|||||||
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
|
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
|
||||||
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
|
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
|
||||||
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
|
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
|
||||||
document.getElementById('noteEditBtn2')?.addEventListener('click', _showNoteEditMode);
|
|
||||||
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
|
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
|
||||||
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
|
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
|
||||||
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
|
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
|
||||||
|
|||||||
@@ -418,6 +418,7 @@ const UI = {
|
|||||||
${editBtn}
|
${editBtn}
|
||||||
${regenBtn}
|
${regenBtn}
|
||||||
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
|
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
|
||||||
|
|||||||
Reference in New Issue
Block a user