Feat v0.4.3 backlinks wikilinks (#25)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 4s
CI/CD / test-go-pg (push) Successful in 2m41s
CI/CD / test-sqlite (push) Successful in 2m43s
CI/CD / build-and-deploy (push) Successful in 25s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #25.
This commit is contained in:
2026-03-29 17:05:44 +00:00
committed by xcaliber
parent 32beb3cee4
commit 31ab572c95
7 changed files with 391 additions and 16 deletions

View File

@@ -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>
`;
}