Feat v0.4.3 backlinks wikilinks
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 21s
CI/CD / test-frontend (pull_request) Successful in 22s
CI/CD / test-go-pg (pull_request) Successful in 2m34s
CI/CD / test-sqlite (pull_request) Successful in 3m3s
CI/CD / build-and-deploy (pull_request) Successful in 1m21s
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 21s
CI/CD / test-frontend (pull_request) Successful in 22s
CI/CD / test-go-pg (pull_request) Successful in 2m34s
CI/CD / test-sqlite (pull_request) Successful in 3m3s
CI/CD / build-and-deploy (pull_request) Successful in 1m21s
Obsidian-style [[wikilinks]] for the notes surface. New ext_notes_links table (source_id, target_id, link_text) with bidirectional indexes. Backend: _extract_wikilinks() parses [[...]] via split (no regex in Starlark). _sync_links() on create/update resolves titles to note IDs. Cascade delete cleans both directions. GET /links/:id and GET /backlinks/:id endpoints. Frontend: wikilinks render as clickable links in preview — blue for resolved, red for unresolved. Clicking resolved navigates; clicking unresolved creates the note and re-saves source to resolve the link. BacklinksPanel shows incoming links below editor with click navigation. Notes package 0.3.0 → 0.4.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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, '<em>$1</em>');
|
||||
// links
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||
// wikilinks: [[Note Title]] → clickable internal link
|
||||
s = s.replace(/\[\[([^\]]+)\]\]/g, function(match, linkText) {
|
||||
return '<a class="wikilink" data-wikilink="' + linkText.trim() + '" href="#">' + linkText.trim() + '</a>';
|
||||
});
|
||||
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`
|
||||
<div class="backlinks-panel">
|
||||
<div class="backlinks-panel__header" onClick=${function() { setCollapsed(!collapsed); }}>
|
||||
<span class="backlinks-panel__toggle">${collapsed ? '▶' : '▼'}</span>
|
||||
<span>Backlinks</span>
|
||||
<span class="backlinks-panel__count">${backlinks.length}</span>
|
||||
</div>
|
||||
${!collapsed && backlinks.map(function(bl) {
|
||||
return html`
|
||||
<div key=${bl.source_id} class="backlinks-panel__item"
|
||||
onClick=${function() { if (onNavigate) onNavigate(bl.source_id); }}>
|
||||
<span class="backlinks-panel__title">${bl.source_title}</span>
|
||||
<span class="backlinks-panel__context">via [[${bl.link_text}]]</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 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} />
|
||||
<div class="notes-editor__body">
|
||||
${preview
|
||||
? html`<div class="notes-preview" dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
|
||||
? html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
|
||||
: html`<textarea class="notes-editor__textarea" ref=${textareaRef}
|
||||
value=${body} onInput=${handleBodyChange}
|
||||
onKeyDown=${handleKeyDown}
|
||||
placeholder="Start writing in Markdown…" />`
|
||||
}
|
||||
</div>
|
||||
<${BacklinksPanel} noteId=${note.id} onNavigate=${onNavigate} />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -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} />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user