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` + + `; + } + + // ═══════════════════════════════════════════ // 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`