diff --git a/CHANGELOG.md b/CHANGELOG.md
index 28560e6..a6fd491 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,47 @@
All notable changes to Switchboard Core are documented here.
+## v0.4.3 — Backlinks + Wikilinks
+
+### Added
+
+- **Links table** (`ext_notes_links`): source_id, target_id, link_text columns
+ with indexes on source_id and target_id for bidirectional lookup.
+- **Wikilink extraction**: `_extract_wikilinks()` parses `[[...]]` patterns
+ using `split("[[")` + `find("]]")` (Starlark has no regex or while loops).
+ Returns deduplicated list of link text strings.
+- **Link sync on save**: `_sync_links()` called from `_create_note` and
+ `_update_note` when body changes. Follows the tags delete-all + reinsert
+ pattern. Resolves link text to note IDs via case-insensitive title matching.
+ Unresolved links stored with empty `target_id`.
+- **Cascade delete**: Hard-deleting a note removes both outgoing links
+ (source_id matches) and incoming backlinks (target_id matches).
+- **Link API endpoints**: `GET /links/:note_id` returns outgoing links.
+ `GET /backlinks/:note_id` returns incoming links enriched with source note
+ titles to avoid extra frontend round-trips.
+- **Wikilink preview rendering**: `[[Note Title]]` syntax renders as clickable
+ accent-colored links in the markdown preview. Unresolved wikilinks styled
+ in danger/red with dashed underline.
+- **Wikilink click navigation**: Clicking a resolved wikilink navigates to the
+ target note. Clicking an unresolved wikilink creates the note and navigates
+ to it, then re-saves the source note so the link resolves to blue.
+- **Backlinks panel**: Collapsible panel below the editor showing all notes
+ that link to the current note. Each backlink displays source title and link
+ text. Click to navigate. Hidden when no backlinks exist.
+- **Updated stats**: `/stats` response now includes `links` count.
+
+### Data model
+
+| Table | Columns |
+|-------|---------|
+| `ext_notes_links` | source_id, target_id, link_text |
+
+### Notes package version
+
+0.3.0 → 0.4.0
+
+---
+
## v0.4.2 — Tags + Search
### Added
diff --git a/ROADMAP.md b/ROADMAP.md
index f23c7b4..b2ea8a3 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,6 +1,6 @@
# Switchboard Core — Roadmap
-## Current: v0.4.2 — Tags + Search
+## Current: v0.4.3 — Backlinks + Wikilinks
Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
features removed from the kernel. What remains is the minimum viable
@@ -267,11 +267,19 @@ Zero platform special-casing. Proves the full extension stack E2E.
| Drag-and-drop | ✅ | `NoteCard` is draggable. `FolderNode`, "All Notes", and "Unfiled" are drop targets. Uses existing move-note API. |
| Folder context menu | ✅ | Replaced `prompt()` hack with proper right-click popup menu: Add subfolder, Rename, Delete. Positioned at click coordinates, dismissed on outside click. |
-### v0.4.3 — Backlinks + Wikilinks (planned)
+### v0.4.3 — Backlinks + Wikilinks (complete)
-- `note_links` table (source, target, link_text)
-- `[[wikilink]]` extraction on save
-- Backlinks panel, clickable links in preview
+| Step | Status | Description |
+|------|--------|-------------|
+| Links table | ✅ | `ext_notes_links` — source_id, target_id, link_text. Indexed on both source_id and target_id for bidirectional lookup. |
+| Wikilink extraction | ✅ | `_extract_wikilinks()` parses `[[...]]` using `split("[[")` + `find("]]")` (no regex/while in Starlark). Deduplicates by lowercase. |
+| Link sync on save | ✅ | `_sync_links()` called from `_create_note` and `_update_note`. Delete-all + reinsert pattern (matches tags). Resolves titles to note IDs (case-insensitive). Unresolved links stored with `target_id=""`. |
+| Cascade delete | ✅ | Hard-deleting a note removes both outgoing links (source_id) and incoming backlinks (target_id). |
+| Link endpoints | ✅ | `GET /links/:note_id` (outgoing) and `GET /backlinks/:note_id` (incoming, enriched with source titles). |
+| Wikilink preview | ✅ | `[[Note Title]]` rendered as clickable accent-colored links in markdown preview. Unresolved links styled in danger/red. |
+| Wikilink navigation | ✅ | Clicking a resolved wikilink navigates to the target note. Clicking an unresolved (red) link creates the note, navigates to it, and re-saves the source so the link resolves. |
+| Backlinks panel | ✅ | Collapsible panel below editor showing all notes linking to current note. Click to navigate. Hidden when empty. |
+| Stats update | ✅ | `/stats` includes `links` count. Notes package version bumped to 0.4.0. |
### v0.4.4 — Rich Editor + Import/Export (planned)
diff --git a/VERSION b/VERSION
index 2b7c5ae..17b2ccd 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.4.2
+0.4.3
diff --git a/packages/notes/css/main.css b/packages/notes/css/main.css
index b301282..df4ab58 100644
--- a/packages/notes/css/main.css
+++ b/packages/notes/css/main.css
@@ -529,6 +529,80 @@
outline-offset: -2px;
}
+/* ── Wikilinks ──────────────────────────── */
+.wikilink {
+ color: var(--accent);
+ text-decoration: none;
+ border-bottom: 1px dashed var(--accent);
+ cursor: pointer;
+}
+.wikilink:hover {
+ border-bottom-style: solid;
+}
+.wikilink.wikilink--unresolved {
+ color: var(--danger, #e53e3e);
+ border-bottom-color: var(--danger, #e53e3e);
+ opacity: 0.7;
+}
+.wikilink.wikilink--unresolved:hover {
+ opacity: 1;
+}
+
+/* ── Backlinks Panel ────────────────────── */
+.backlinks-panel {
+ border-top: 1px solid var(--border);
+ padding: 10px 16px;
+ background: var(--bg-raised);
+ flex-shrink: 0;
+ max-height: 200px;
+ overflow-y: auto;
+}
+.backlinks-panel__header {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-2);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 6px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ user-select: none;
+}
+.backlinks-panel__toggle {
+ font-size: 9px;
+ color: var(--text-2);
+}
+.backlinks-panel__count {
+ background: var(--bg-hover);
+ color: var(--text-2);
+ font-size: 10px;
+ padding: 0 6px;
+ border-radius: 8px;
+ line-height: 16px;
+}
+.backlinks-panel__item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 8px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 13px;
+}
+.backlinks-panel__item:hover {
+ background: var(--bg-hover);
+}
+.backlinks-panel__title {
+ color: var(--accent);
+ font-weight: 500;
+}
+.backlinks-panel__context {
+ color: var(--text-2);
+ font-size: 11px;
+}
+
/* ── Responsive ──────────────────────────── */
@media (max-width: 700px) {
.notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
diff --git a/packages/notes/js/main.js b/packages/notes/js/main.js
index 7d82c5e..d40c6d0 100644
--- a/packages/notes/js/main.js
+++ b/packages/notes/js/main.js
@@ -1,5 +1,5 @@
/**
- * Notes — Surface Entry Point (v0.3.0)
+ * Notes — Surface Entry Point (v0.4.0)
*
* Markdown notes surface using the SDK:
* sw.api.ext('notes') — scoped API client
@@ -138,6 +138,10 @@
s = s.replace(/_(.+?)_/g, '$1');
// links
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
+ // wikilinks: [[Note Title]] → clickable internal link
+ s = s.replace(/\[\[([^\]]+)\]\]/g, function(match, linkText) {
+ return '' + linkText.trim() + '';
+ });
return s;
}
@@ -490,17 +494,57 @@
}
+ // ═══════════════════════════════════════════
+ // BacklinksPanel — incoming wikilinks
+ // ═══════════════════════════════════════════
+
+ function BacklinksPanel({ noteId, onNavigate }) {
+ var [backlinks, setBacklinks] = useState([]);
+ var [collapsed, setCollapsed] = useState(false);
+
+ useEffect(function() {
+ if (!noteId) { setBacklinks([]); return; }
+ api.get('/backlinks/' + noteId).then(function(res) {
+ var items = (res && res.data) || res || [];
+ setBacklinks(Array.isArray(items) ? items : []);
+ }).catch(function() { setBacklinks([]); });
+ }, [noteId]);
+
+ if (!backlinks.length) return null;
+
+ return html`
+
+
+ ${!collapsed && backlinks.map(function(bl) {
+ return html`
+
+ ${bl.source_title}
+ via [[${bl.link_text}]]
+
+ `;
+ })}
+
+ `;
+ }
+
+
// ═══════════════════════════════════════════
// EditorPane — note editor + preview + tags
// ═══════════════════════════════════════════
- function EditorPane({ note, folders, allTags, onSave, onDelete, onRefresh, onTagsChanged }) {
+ function EditorPane({ note, folders, allTags, onSave, onDelete, onRefresh, onTagsChanged, onNavigate, onNavigateToTitle }) {
var [title, setTitle] = useState(note ? note.title : '');
var [body, setBody] = useState(note ? note.body : '');
var [noteTags, setNoteTags] = useState([]);
var [preview, setPreview] = useState(false);
var [dirty, setDirty] = useState(false);
var [saving, setSaving] = useState(false);
+ var [saveCount, setSaveCount] = useState(0);
var textareaRef = useRef(null);
// sync when note changes
@@ -532,6 +576,7 @@
try {
await api.put('/notes/' + note.id, { title: t || 'Untitled', body: b });
setDirty(false);
+ setSaveCount(function(c) { return c + 1; });
onRefresh();
} catch (e) {
console.error('Save failed:', e);
@@ -584,7 +629,46 @@
}
}
- var previewHtml = useMemo(function() { return renderMarkdown(body); }, [body]);
+ // ── Outgoing links for resolved/unresolved styling ──
+ var [outgoingLinks, setOutgoingLinks] = useState([]);
+ useEffect(function() {
+ if (!note) { setOutgoingLinks([]); return; }
+ api.get('/links/' + note.id).then(function(res) {
+ var items = (res && res.data) || res || [];
+ setOutgoingLinks(Array.isArray(items) ? items : []);
+ }).catch(function() { setOutgoingLinks([]); });
+ }, [note ? note.id : null, saveCount]);
+
+ var previewHtml = useMemo(function() {
+ var h = renderMarkdown(body);
+ // mark unresolved wikilinks
+ for (var i = 0; i < outgoingLinks.length; i++) {
+ var link = outgoingLinks[i];
+ if (!link.target_id) {
+ var search = 'class="wikilink" data-wikilink="' + escHtml(link.link_text) + '"';
+ var replace = 'class="wikilink wikilink--unresolved" data-wikilink="' + escHtml(link.link_text) + '"';
+ h = h.split(search).join(replace);
+ }
+ }
+ return h;
+ }, [body, outgoingLinks]);
+
+ // ── Wikilink click handling in preview ──
+ var previewRef = useRef(null);
+ useEffect(function() {
+ var el = previewRef.current;
+ if (!el || !preview) return;
+ function handleClick(e) {
+ var target = e.target;
+ if (target.classList && target.classList.contains('wikilink')) {
+ e.preventDefault();
+ var linkText = target.getAttribute('data-wikilink');
+ if (linkText && onNavigateToTitle) onNavigateToTitle(linkText);
+ }
+ }
+ el.addEventListener('click', handleClick);
+ return function() { el.removeEventListener('click', handleClick); };
+ }, [preview, onNavigateToTitle]);
// build indented folder list for the select dropdown
var folderOptions = useMemo(function() {
@@ -660,13 +744,14 @@
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
${preview
- ? html`
`
+ ? html`
`
: html`
`
}
+ <${BacklinksPanel} noteId=${note.id} onNavigate=${onNavigate} />
`;
}
@@ -866,6 +951,41 @@
} catch (e) { console.error('Delete folder failed:', e); }
}
+ // ── Wikilink navigation by title ─────────
+ async function handleNavigateToTitle(title) {
+ try {
+ var res = await api.get('/search?q=' + encodeURIComponent(title.trim()));
+ var items = (res && res.data) || res || [];
+ if (!Array.isArray(items)) items = [];
+ var match = null;
+ for (var i = 0; i < items.length; i++) {
+ if ((items[i].title || '').toLowerCase().trim() === title.toLowerCase().trim()) {
+ match = items[i];
+ break;
+ }
+ }
+ if (match) {
+ handleSelect(match.id);
+ } else {
+ // create the note and navigate to it
+ var sourceId = activeId;
+ var newNote = await api.post('/notes', { title: title.trim(), body: '' });
+ if (newNote && newNote.id) {
+ // re-save source note so _sync_links resolves the new target
+ if (sourceId && activeNote) {
+ await api.put('/notes/' + sourceId, { body: activeNote.body || '' });
+ }
+ setActiveId(newNote.id);
+ loadNote(newNote.id);
+ loadNotes();
+ loadStats();
+ }
+ }
+ } catch (e) {
+ console.error('Navigate to title failed:', e);
+ }
+ }
+
// ── Search ────────────────────────────────
function handleSearch(e) {
var val = e.target.value;
@@ -916,7 +1036,9 @@
onSave=${handleRefresh}
onDelete=${handleDelete}
onRefresh=${handleRefresh}
- onTagsChanged=${handleTagsChanged} />
+ onTagsChanged=${handleTagsChanged}
+ onNavigate=${handleSelect}
+ onNavigateToTitle=${handleNavigateToTitle} />
`;
}
diff --git a/packages/notes/manifest.json b/packages/notes/manifest.json
index 531f5af..63a66bc 100644
--- a/packages/notes/manifest.json
+++ b/packages/notes/manifest.json
@@ -6,7 +6,7 @@
"route": "/s/notes",
"auth": "authenticated",
"layout": "single",
- "version": "0.3.0",
+ "version": "0.4.0",
"icon": "📝",
"description": "Markdown notes surface with folders, tags, and backlinks.",
"author": "switchboard",
@@ -28,7 +28,9 @@
{"method": "POST", "path": "/notes/move"},
{"method": "GET", "path": "/tags"},
{"method": "GET", "path": "/tags/*"},
- {"method": "PUT", "path": "/tags/*"}
+ {"method": "PUT", "path": "/tags/*"},
+ {"method": "GET", "path": "/links/*"},
+ {"method": "GET", "path": "/backlinks/*"}
],
"db_tables": {
@@ -59,6 +61,17 @@
["tag"]
]
},
+ "links": {
+ "columns": {
+ "source_id": "text",
+ "target_id": "text",
+ "link_text": "text"
+ },
+ "indexes": [
+ ["source_id"],
+ ["target_id"]
+ ]
+ },
"folders": {
"columns": {
"name": "text",
diff --git a/packages/notes/script.star b/packages/notes/script.star
index 72c1caf..f262f27 100644
--- a/packages/notes/script.star
+++ b/packages/notes/script.star
@@ -1,4 +1,4 @@
-# Notes — Starlark Backend (v0.3.0)
+# Notes — Starlark Backend (v0.4.0)
#
# Markdown notes surface using ext_data.
#
@@ -55,6 +55,56 @@ def _tags_for_notes(note_ids):
return result
+def _extract_wikilinks(text):
+ """Parse [[...]] patterns from text. Returns deduplicated list of link_text strings."""
+ links = []
+ seen = {}
+ parts = text.split("[[")
+ # parts[0] is before any wikilink; parts[1:] each start after "[["
+ for i in range(1, len(parts)):
+ chunk = parts[i]
+ end = chunk.find("]]")
+ if end > 0:
+ link_text = chunk[:end].strip()
+ lt_lower = link_text.lower()
+ if link_text and lt_lower not in seen:
+ seen[lt_lower] = True
+ links.append(link_text)
+ return links
+
+
+def _sync_links(source_id, body):
+ """Extract wikilinks from body, resolve to note IDs, replace link rows."""
+ raw_links = _extract_wikilinks(_str(body))
+ if not raw_links and not db.query("links", filters={"source_id": source_id}, limit=1):
+ return # nothing to do
+
+ # resolve titles to note IDs (case-insensitive)
+ all_notes = db.query("notes", filters={"archived": 0}, limit=5000)
+ title_map = {}
+ for n in (all_notes or []):
+ t = _str(n.get("title", "")).lower().strip()
+ if t:
+ title_map[t] = n.get("id", "")
+
+ # delete existing links for this source
+ old_links = db.query("links", filters={"source_id": source_id}, limit=500)
+ for link in (old_links or []):
+ db.delete("links", link["id"])
+
+ # insert new links
+ for lt in raw_links:
+ target_id = title_map.get(lt.lower().strip(), "")
+ # skip self-links
+ if target_id == source_id:
+ target_id = ""
+ db.insert("links", {
+ "source_id": source_id,
+ "target_id": target_id,
+ "link_text": lt,
+ })
+
+
# ═══════════════════════════════════════════════
# Surface API routes (/s/notes/api/*)
# ═══════════════════════════════════════════════
@@ -83,6 +133,15 @@ def on_request(req):
if method == "POST" and path == "/notes/move":
return _move_note(req)
+ # ── Link routes ──────────────────────────
+ # GET /links/:note_id — outgoing links
+ if method == "GET" and path.startswith("/links/"):
+ return _get_links(path[len("/links/"):])
+
+ # GET /backlinks/:note_id — incoming links
+ if method == "GET" and path.startswith("/backlinks/"):
+ return _get_backlinks(path[len("/backlinks/"):])
+
# ── Tag routes ────────────────────────────
# GET /tags — all unique tags
if method == "GET" and path == "/tags":
@@ -195,15 +254,17 @@ def _create_note(req):
if not title:
title = "Untitled"
+ body_text = _str(body.get("body", ""))
row = db.insert("notes", {
"title": title,
- "body": _str(body.get("body", "")),
+ "body": body_text,
"folder_id": _str(body.get("folder_id", "")),
"creator_id": user_id,
"updated_at": "",
"pinned": _int(body.get("pinned", 0)),
"archived": 0,
})
+ _sync_links(row["id"], body_text)
return _resp(201, row)
@@ -247,6 +308,10 @@ def _update_note(note_id, req):
if not ok:
return _resp(500, {"error": "update failed"})
+ # sync wikilinks when body changes
+ if "body" in body:
+ _sync_links(note_id, _str(body["body"]))
+
rows = db.query("notes", filters={"id": note_id}, limit=1)
return _resp(200, rows[0] if rows else {})
@@ -260,6 +325,14 @@ def _delete_note(note_id, req):
note_tags = db.query("tags", filters={"note_id": note_id}, limit=100)
for t in (note_tags or []):
db.delete("tags", t["id"])
+ # cascade-delete outgoing links FROM this note
+ out_links = db.query("links", filters={"source_id": note_id}, limit=500)
+ for link in (out_links or []):
+ db.delete("links", link["id"])
+ # cascade-delete incoming backlinks TO this note
+ in_links = db.query("links", filters={"target_id": note_id}, limit=500)
+ for link in (in_links or []):
+ db.delete("links", link["id"])
ok = db.delete("notes", note_id)
if not ok:
return _resp(404, {"error": "note not found"})
@@ -350,6 +423,46 @@ def _set_note_tags(note_id, req):
return _resp(200, {"data": clean_tags})
+# ═══════════════════════════════════════════════
+# Links (backlinks + wikilinks)
+# ═══════════════════════════════════════════════
+
+def _get_links(note_id):
+ """Return outgoing links for a note."""
+ rows = db.query("links", filters={"source_id": note_id}, limit=500)
+ items = []
+ for r in (rows or []):
+ items.append({
+ "source_id": r.get("source_id", ""),
+ "target_id": r.get("target_id", ""),
+ "link_text": r.get("link_text", ""),
+ })
+ return _resp(200, {"data": items})
+
+
+def _get_backlinks(note_id):
+ """Return notes that link TO this note (backlinks), enriched with source titles."""
+ rows = db.query("links", filters={"target_id": note_id}, limit=500)
+ if not rows:
+ return _resp(200, {"data": []})
+
+ # batch-fetch source note titles
+ all_notes = db.query("notes", filters={"archived": 0}, limit=5000)
+ note_map = {}
+ for n in (all_notes or []):
+ note_map[n.get("id", "")] = n.get("title", "Untitled")
+
+ items = []
+ for r in rows:
+ sid = r.get("source_id", "")
+ items.append({
+ "source_id": sid,
+ "source_title": note_map.get(sid, "Unknown"),
+ "link_text": r.get("link_text", ""),
+ })
+ return _resp(200, {"data": items})
+
+
# ═══════════════════════════════════════════════
# Folder CRUD
# ═══════════════════════════════════════════════
@@ -506,4 +619,8 @@ def _get_stats():
tag_seen[tag] = True
tag_count = len(tag_seen)
- return _resp(200, {"total": total, "pinned": pinned, "archived": archived, "unfiled": unfiled, "folders": folder_count, "tags": tag_count})
+ # count links
+ all_links = db.query("links", limit=10000)
+ link_count = len(all_links or [])
+
+ return _resp(200, {"total": total, "pinned": pinned, "archived": archived, "unfiled": unfiled, "folders": folder_count, "tags": tag_count, "links": link_count})