Feat v0.4.3 backlinks wikilinks (#25)
All checks were successful
All checks were successful
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:
41
CHANGELOG.md
41
CHANGELOG.md
@@ -2,6 +2,47 @@
|
|||||||
|
|
||||||
All notable changes to Switchboard Core are documented here.
|
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
|
## v0.4.2 — Tags + Search
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
18
ROADMAP.md
18
ROADMAP.md
@@ -1,6 +1,6 @@
|
|||||||
# Switchboard Core — Roadmap
|
# 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
|
Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
|
||||||
features removed from the kernel. What remains is the minimum viable
|
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. |
|
| 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. |
|
| 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)
|
| Step | Status | Description |
|
||||||
- `[[wikilink]]` extraction on save
|
|------|--------|-------------|
|
||||||
- Backlinks panel, clickable links in preview
|
| 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)
|
### v0.4.4 — Rich Editor + Import/Export (planned)
|
||||||
|
|
||||||
|
|||||||
@@ -529,6 +529,80 @@
|
|||||||
outline-offset: -2px;
|
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 ──────────────────────────── */
|
/* ── Responsive ──────────────────────────── */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
|
.notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Notes — Surface Entry Point (v0.3.0)
|
* Notes — Surface Entry Point (v0.4.0)
|
||||||
*
|
*
|
||||||
* Markdown notes surface using the SDK:
|
* Markdown notes surface using the SDK:
|
||||||
* sw.api.ext('notes') — scoped API client
|
* sw.api.ext('notes') — scoped API client
|
||||||
@@ -138,6 +138,10 @@
|
|||||||
s = s.replace(/_(.+?)_/g, '<em>$1</em>');
|
s = s.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||||
// links
|
// links
|
||||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
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;
|
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
|
// 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 [title, setTitle] = useState(note ? note.title : '');
|
||||||
var [body, setBody] = useState(note ? note.body : '');
|
var [body, setBody] = useState(note ? note.body : '');
|
||||||
var [noteTags, setNoteTags] = useState([]);
|
var [noteTags, setNoteTags] = useState([]);
|
||||||
var [preview, setPreview] = useState(false);
|
var [preview, setPreview] = useState(false);
|
||||||
var [dirty, setDirty] = useState(false);
|
var [dirty, setDirty] = useState(false);
|
||||||
var [saving, setSaving] = useState(false);
|
var [saving, setSaving] = useState(false);
|
||||||
|
var [saveCount, setSaveCount] = useState(0);
|
||||||
var textareaRef = useRef(null);
|
var textareaRef = useRef(null);
|
||||||
|
|
||||||
// sync when note changes
|
// sync when note changes
|
||||||
@@ -532,6 +576,7 @@
|
|||||||
try {
|
try {
|
||||||
await api.put('/notes/' + note.id, { title: t || 'Untitled', body: b });
|
await api.put('/notes/' + note.id, { title: t || 'Untitled', body: b });
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
|
setSaveCount(function(c) { return c + 1; });
|
||||||
onRefresh();
|
onRefresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Save failed:', 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
|
// build indented folder list for the select dropdown
|
||||||
var folderOptions = useMemo(function() {
|
var folderOptions = useMemo(function() {
|
||||||
@@ -660,13 +744,14 @@
|
|||||||
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
|
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
|
||||||
<div class="notes-editor__body">
|
<div class="notes-editor__body">
|
||||||
${preview
|
${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}
|
: html`<textarea class="notes-editor__textarea" ref=${textareaRef}
|
||||||
value=${body} onInput=${handleBodyChange}
|
value=${body} onInput=${handleBodyChange}
|
||||||
onKeyDown=${handleKeyDown}
|
onKeyDown=${handleKeyDown}
|
||||||
placeholder="Start writing in Markdown…" />`
|
placeholder="Start writing in Markdown…" />`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<${BacklinksPanel} noteId=${note.id} onNavigate=${onNavigate} />
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -866,6 +951,41 @@
|
|||||||
} catch (e) { console.error('Delete folder failed:', e); }
|
} 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 ────────────────────────────────
|
// ── Search ────────────────────────────────
|
||||||
function handleSearch(e) {
|
function handleSearch(e) {
|
||||||
var val = e.target.value;
|
var val = e.target.value;
|
||||||
@@ -916,7 +1036,9 @@
|
|||||||
onSave=${handleRefresh}
|
onSave=${handleRefresh}
|
||||||
onDelete=${handleDelete}
|
onDelete=${handleDelete}
|
||||||
onRefresh=${handleRefresh}
|
onRefresh=${handleRefresh}
|
||||||
onTagsChanged=${handleTagsChanged} />
|
onTagsChanged=${handleTagsChanged}
|
||||||
|
onNavigate=${handleSelect}
|
||||||
|
onNavigateToTitle=${handleNavigateToTitle} />
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"route": "/s/notes",
|
"route": "/s/notes",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
"layout": "single",
|
"layout": "single",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"icon": "📝",
|
"icon": "📝",
|
||||||
"description": "Markdown notes surface with folders, tags, and backlinks.",
|
"description": "Markdown notes surface with folders, tags, and backlinks.",
|
||||||
"author": "switchboard",
|
"author": "switchboard",
|
||||||
@@ -28,7 +28,9 @@
|
|||||||
{"method": "POST", "path": "/notes/move"},
|
{"method": "POST", "path": "/notes/move"},
|
||||||
{"method": "GET", "path": "/tags"},
|
{"method": "GET", "path": "/tags"},
|
||||||
{"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": {
|
"db_tables": {
|
||||||
@@ -59,6 +61,17 @@
|
|||||||
["tag"]
|
["tag"]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"links": {
|
||||||
|
"columns": {
|
||||||
|
"source_id": "text",
|
||||||
|
"target_id": "text",
|
||||||
|
"link_text": "text"
|
||||||
|
},
|
||||||
|
"indexes": [
|
||||||
|
["source_id"],
|
||||||
|
["target_id"]
|
||||||
|
]
|
||||||
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
"columns": {
|
"columns": {
|
||||||
"name": "text",
|
"name": "text",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Notes — Starlark Backend (v0.3.0)
|
# Notes — Starlark Backend (v0.4.0)
|
||||||
#
|
#
|
||||||
# Markdown notes surface using ext_data.
|
# Markdown notes surface using ext_data.
|
||||||
#
|
#
|
||||||
@@ -55,6 +55,56 @@ def _tags_for_notes(note_ids):
|
|||||||
return result
|
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/*)
|
# Surface API routes (/s/notes/api/*)
|
||||||
# ═══════════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
@@ -83,6 +133,15 @@ def on_request(req):
|
|||||||
if method == "POST" and path == "/notes/move":
|
if method == "POST" and path == "/notes/move":
|
||||||
return _move_note(req)
|
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 ────────────────────────────
|
# ── Tag routes ────────────────────────────
|
||||||
# GET /tags — all unique tags
|
# GET /tags — all unique tags
|
||||||
if method == "GET" and path == "/tags":
|
if method == "GET" and path == "/tags":
|
||||||
@@ -195,15 +254,17 @@ def _create_note(req):
|
|||||||
if not title:
|
if not title:
|
||||||
title = "Untitled"
|
title = "Untitled"
|
||||||
|
|
||||||
|
body_text = _str(body.get("body", ""))
|
||||||
row = db.insert("notes", {
|
row = db.insert("notes", {
|
||||||
"title": title,
|
"title": title,
|
||||||
"body": _str(body.get("body", "")),
|
"body": body_text,
|
||||||
"folder_id": _str(body.get("folder_id", "")),
|
"folder_id": _str(body.get("folder_id", "")),
|
||||||
"creator_id": user_id,
|
"creator_id": user_id,
|
||||||
"updated_at": "",
|
"updated_at": "",
|
||||||
"pinned": _int(body.get("pinned", 0)),
|
"pinned": _int(body.get("pinned", 0)),
|
||||||
"archived": 0,
|
"archived": 0,
|
||||||
})
|
})
|
||||||
|
_sync_links(row["id"], body_text)
|
||||||
return _resp(201, row)
|
return _resp(201, row)
|
||||||
|
|
||||||
|
|
||||||
@@ -247,6 +308,10 @@ def _update_note(note_id, req):
|
|||||||
if not ok:
|
if not ok:
|
||||||
return _resp(500, {"error": "update failed"})
|
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)
|
rows = db.query("notes", filters={"id": note_id}, limit=1)
|
||||||
return _resp(200, rows[0] if rows else {})
|
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)
|
note_tags = db.query("tags", filters={"note_id": note_id}, limit=100)
|
||||||
for t in (note_tags or []):
|
for t in (note_tags or []):
|
||||||
db.delete("tags", t["id"])
|
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)
|
ok = db.delete("notes", note_id)
|
||||||
if not ok:
|
if not ok:
|
||||||
return _resp(404, {"error": "note not found"})
|
return _resp(404, {"error": "note not found"})
|
||||||
@@ -350,6 +423,46 @@ def _set_note_tags(note_id, req):
|
|||||||
return _resp(200, {"data": clean_tags})
|
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
|
# Folder CRUD
|
||||||
# ═══════════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
@@ -506,4 +619,8 @@ def _get_stats():
|
|||||||
tag_seen[tag] = True
|
tag_seen[tag] = True
|
||||||
tag_count = len(tag_seen)
|
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})
|
||||||
|
|||||||
Reference in New Issue
Block a user