Feat v0.4.0 notes surface (#22)

Add Notes as a standard installable surface package (type: full,
tier: starlark). Markdown editor with sidebar note list, live
preview toggle, auto-save, pin/archive, and full-text search.
Zero kernel changes — proves extension stack E2E.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 23:45:17 +00:00
parent 310048b7bb
commit 5e455c08cb
6 changed files with 1120 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
# Switchboard Core — Roadmap
## Current: v0.3.7 — Package Audit
## Current: v0.4.0 — Notes Surface
Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
features removed from the kernel. What remains is the minimum viable
@@ -225,15 +225,46 @@ Builder image for faster builds, bundled packages for zero-config first run.
| Distribution docs | ✅ | `docs/DISTRIBUTION.md` — quick start, bundled packages, builder image, custom builds, production deployment. |
| Tests | ✅ | 6 handler tests (fresh install, skip existing, missing dir, empty dir, dormant handling, allowlist filtering). All existing tests pass. |
## v0.4.0 — Notes Surface
## v0.4.x — Notes Surface
Obsidian-style rich-text notes rebuilt as an installable surface package.
Obsidian-style notes rebuilt as an installable surface package.
Zero platform special-casing. Proves the full extension stack E2E.
- Notes as `.pkg` archive
- Rich text editor (ProseMirror or similar)
- Folder tree, backlinks, tags — all extension-provided
- Markdown import/export
### v0.4.0 — Core Notes CRUD + Markdown Editor
| Step | Status | Description |
|------|--------|-------------|
| Package scaffold | ✅ | `packages/notes/` with manifest, script.star, JS, CSS, README. Type: full, tier: starlark. |
| Notes CRUD backend | ✅ | Starlark `on_request()` — list, create, get, update, delete, search, stats. Lightweight list projection (no body). |
| Notes table | ✅ | `ext_notes_notes` — title, body (TEXT), folder_id, pinned, archived, creator_id, updated_at. |
| Markdown editor | ✅ | Preact+htm frontend: sidebar note list, title input, markdown textarea, auto-save (1s debounce). |
| Live preview | ✅ | Inline markdown renderer (~100 lines): headings, bold, italic, code, links, lists, blockquotes, hr. |
| Search | ✅ | Client-side LIKE search over title/body. Search bar in sidebar. |
| Pin/archive | ✅ | Pin notes to top, soft-delete via archive, hard delete with `?hard=1`. |
### v0.4.1 — Folders + Navigation Tree (planned)
- `folders` table (name, parent_id, sort_order)
- Folder CRUD API (4 routes) + note move
- Collapsible folder tree in sidebar
- Breadcrumb trail in editor
### v0.4.2 — Tags + Search (planned)
- `note_tags` table (note_id, tag)
- Tag management inline with note save
- Tag filter in sidebar, tag pills on cards
### v0.4.3 — Backlinks + Wikilinks (planned)
- `note_links` table (source, target, link_text)
- `[[wikilink]]` extraction on save
- Backlinks panel, clickable links in preview
### v0.4.4 — Rich Editor + Import/Export (planned)
- Vendored CodeMirror 6 markdown bundle
- Markdown file import/export
## v0.5.0 — MVP

52
packages/notes/README.md Normal file
View File

@@ -0,0 +1,52 @@
# Notes
Markdown notes surface with sidebar navigation, live preview, and search.
## Status: v0.1.0
Core CRUD with markdown textarea and inline preview.
## Features
- Create, edit, archive notes
- Markdown editing with live preview toggle
- Auto-save with debounce (1s)
- Pin important notes to top
- Full-text search across title and body
- Light/dark theme support
## Planned
- v0.4.1: Folders + navigation tree
- v0.4.2: Tags + enhanced search
- v0.4.3: Backlinks + `[[wikilinks]]`
- v0.4.4: Rich editor (CodeMirror 6) + import/export
## Data
Single `notes` table in ext_data:
| Column | Type | Description |
|--------|------|-------------|
| title | text | Note title |
| body | text | Markdown content |
| folder_id | text | Folder reference (v0.4.1) |
| pinned | int | 0 or 1 |
| archived | int | Soft delete flag |
## API
| Method | Path | Description |
|--------|------|-------------|
| GET | /notes | List notes (lightweight, no body) |
| POST | /notes | Create note |
| GET | /notes/:id | Get note with full body |
| PUT | /notes/:id | Update note |
| DELETE | /notes/:id | Archive (or hard delete with ?hard=1) |
| GET | /search?q=term | Search title and body |
| GET | /stats | Note counts |
## Keyboard Shortcuts
- `Ctrl/Cmd+S` — Force save
- `Tab` — Insert two spaces

294
packages/notes/css/main.css Normal file
View File

@@ -0,0 +1,294 @@
/* ── Notes Surface Styles ─────────────────── */
.notes-app {
display: flex;
height: 100%;
overflow: hidden;
}
/* ── Sidebar ─────────────────────────────── */
.notes-sidebar {
width: 280px;
min-width: 220px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
background: var(--bg-raised);
flex-shrink: 0;
}
.notes-sidebar__header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.notes-sidebar__title {
font-size: 14px;
font-weight: 600;
color: var(--text);
flex: 1;
}
.notes-sidebar__count {
font-size: 12px;
color: var(--text-3);
}
.notes-sidebar__actions {
display: flex;
gap: 4px;
}
/* ── Search ──────────────────────────────── */
.notes-search {
padding: 8px 14px;
flex-shrink: 0;
}
.notes-search input {
width: 100%;
padding: 6px 10px;
font-size: 13px;
background: var(--bg-surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--font);
}
.notes-search input:focus {
outline: none;
border-color: var(--accent);
}
.notes-search input::placeholder {
color: var(--text-3);
}
/* ── Note List ───────────────────────────── */
.notes-list {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
}
.notes-list__empty {
padding: 40px 16px;
text-align: center;
color: var(--text-3);
font-size: 13px;
}
.notes-list__loading {
padding: 40px 0;
display: flex;
justify-content: center;
color: var(--text-3);
}
/* ── Note Card (sidebar item) ────────────── */
.note-card {
padding: 10px 12px;
border-radius: var(--radius);
cursor: pointer;
transition: var(--transition);
margin-bottom: 2px;
}
.note-card:hover {
background: var(--bg-hover);
}
.note-card--active {
background: var(--bg-active);
}
.note-card__title {
font-size: 13px;
font-weight: 500;
color: var(--text);
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-card__pin {
font-size: 11px;
flex-shrink: 0;
}
.note-card__snippet {
font-size: 12px;
color: var(--text-3);
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.note-card__date {
font-size: 11px;
color: var(--text-3);
margin-top: 3px;
}
/* ── Editor Pane ─────────────────────────── */
.notes-editor {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.notes-editor__empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-3);
font-size: 14px;
}
/* ── Editor Header ───────────────────────── */
.notes-editor__header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.notes-editor__title-input {
flex: 1;
font-size: 18px;
font-weight: 600;
color: var(--text);
background: transparent;
border: none;
outline: none;
padding: 4px 0;
font-family: var(--font);
}
.notes-editor__title-input::placeholder {
color: var(--text-3);
}
.notes-editor__actions {
display: flex;
gap: 6px;
align-items: center;
}
/* ── Editor Body ─────────────────────────── */
.notes-editor__body {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.notes-editor__textarea {
flex: 1;
resize: none;
padding: 20px;
font-size: 14px;
line-height: 1.6;
color: var(--text);
background: var(--bg-surface);
border: none;
outline: none;
font-family: var(--mono);
tab-size: 2;
}
.notes-editor__textarea::placeholder {
color: var(--text-3);
}
/* ── Preview ─────────────────────────────── */
.notes-preview {
flex: 1;
overflow-y: auto;
padding: 20px;
border-left: 1px solid var(--border);
background: var(--bg-surface);
}
.notes-preview h1 { font-size: 24px; font-weight: 700; color: var(--text); margin: 0 0 12px; }
.notes-preview h2 { font-size: 20px; font-weight: 600; color: var(--text); margin: 20px 0 8px; }
.notes-preview h3 { font-size: 16px; font-weight: 600; color: var(--text); margin: 16px 0 6px; }
.notes-preview p { font-size: 14px; color: var(--text); line-height: 1.7; margin: 0 0 10px; }
.notes-preview ul, .notes-preview ol { padding-left: 24px; margin: 0 0 10px; color: var(--text); font-size: 14px; line-height: 1.7; }
.notes-preview li { margin-bottom: 2px; }
.notes-preview code {
font-family: var(--mono);
font-size: 13px;
background: var(--bg-raised);
padding: 2px 6px;
border-radius: 3px;
color: var(--accent);
}
.notes-preview pre {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
overflow-x: auto;
margin: 0 0 12px;
}
.notes-preview pre code {
background: none;
padding: 0;
color: var(--text);
font-size: 13px;
}
.notes-preview blockquote {
border-left: 3px solid var(--accent);
margin: 0 0 12px;
padding: 4px 16px;
color: var(--text-2);
}
.notes-preview a { color: var(--accent); text-decoration: none; }
.notes-preview a:hover { text-decoration: underline; }
.notes-preview hr {
border: none;
border-top: 1px solid var(--border);
margin: 16px 0;
}
.notes-preview strong { font-weight: 600; }
.notes-preview em { font-style: italic; }
/* ── Toggle button ───────────────────────── */
.notes-toggle {
padding: 4px 10px;
font-size: 12px;
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-2);
border-radius: var(--radius);
cursor: pointer;
transition: var(--transition);
}
.notes-toggle:hover { background: var(--bg-hover); color: var(--text); }
.notes-toggle--active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* ── Inline buttons ──────────────────────── */
.notes-btn {
border: none;
background: var(--bg-raised);
color: var(--text-2);
cursor: pointer;
border-radius: var(--radius);
transition: var(--transition);
padding: 4px 10px;
font-size: 13px;
}
.notes-btn:hover { background: var(--bg-hover); color: var(--text); }
.notes-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
.notes-btn--accent { background: var(--accent); color: #fff; }
.notes-btn--accent:hover { opacity: 0.9; color: #fff; }
/* ── Saved indicator ─────────────────────── */
.notes-saved {
font-size: 12px;
color: var(--text-3);
padding: 2px 8px;
}
.notes-saved--dirty {
color: var(--warning);
}
/* ── Responsive ──────────────────────────── */
@media (max-width: 700px) {
.notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
.notes-app { flex-direction: column; }
}

431
packages/notes/js/main.js Normal file
View File

@@ -0,0 +1,431 @@
/**
* Notes — Surface Entry Point (v0.1.0)
*
* Markdown notes surface using the SDK:
* sw.api.ext('notes') — scoped API client
* sw.ui.* — primitive components
* sw.shell.Topbar — navigation bar
*/
(async function () {
'use strict';
var mount = document.getElementById('extension-mount');
if (!mount) return;
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
// ── Boot SDK ───────────────────────────────
try {
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
return;
}
var { html } = window;
var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
var { render } = preact;
// ── SDK modules ────────────────────────────
var api = sw.api.ext('notes');
var { Button, Spinner } = sw.ui;
var Topbar = sw.shell.Topbar;
// ── Debounce helper ────────────────────────
var _timers = {};
function debounce(key, fn, ms) {
clearTimeout(_timers[key]);
_timers[key] = setTimeout(fn, ms);
}
// ═══════════════════════════════════════════
// Markdown renderer (inline, no deps)
// ═══════════════════════════════════════════
function renderMarkdown(src) {
if (!src) return '';
var lines = src.split('\n');
var out = [];
var inCode = false;
var inList = false;
var listTag = '';
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// fenced code blocks
if (line.trim().startsWith('```')) {
if (inCode) {
out.push('</code></pre>');
inCode = false;
} else {
if (inList) { out.push('</' + listTag + '>'); inList = false; }
out.push('<pre><code>');
inCode = true;
}
continue;
}
if (inCode) {
out.push(escHtml(line) + '\n');
continue;
}
// blank line
if (!line.trim()) {
if (inList) { out.push('</' + listTag + '>'); inList = false; }
continue;
}
// headings
if (line.startsWith('### ')) { closeList(); out.push('<h3>' + inline(line.slice(4)) + '</h3>'); continue; }
if (line.startsWith('## ')) { closeList(); out.push('<h2>' + inline(line.slice(3)) + '</h2>'); continue; }
if (line.startsWith('# ')) { closeList(); out.push('<h1>' + inline(line.slice(2)) + '</h1>'); continue; }
// hr
if (/^[-*_]{3,}\s*$/.test(line)) { closeList(); out.push('<hr>'); continue; }
// blockquote
if (line.startsWith('> ')) { closeList(); out.push('<blockquote><p>' + inline(line.slice(2)) + '</p></blockquote>'); continue; }
// unordered list
if (/^[\-*+]\s/.test(line)) {
if (!inList || listTag !== 'ul') { closeList(); out.push('<ul>'); inList = true; listTag = 'ul'; }
out.push('<li>' + inline(line.replace(/^[\-*+]\s/, '')) + '</li>');
continue;
}
// ordered list
if (/^\d+\.\s/.test(line)) {
if (!inList || listTag !== 'ol') { closeList(); out.push('<ol>'); inList = true; listTag = 'ol'; }
out.push('<li>' + inline(line.replace(/^\d+\.\s/, '')) + '</li>');
continue;
}
// paragraph
closeList();
out.push('<p>' + inline(line) + '</p>');
}
if (inCode) out.push('</code></pre>');
if (inList) out.push('</' + listTag + '>');
return out.join('\n');
function closeList() { if (inList) { out.push('</' + listTag + '>'); inList = false; } }
}
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function inline(s) {
s = escHtml(s);
// inline code
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
// bold
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
s = s.replace(/__(.+?)__/g, '<strong>$1</strong>');
// italic
s = s.replace(/\*(.+?)\*/g, '<em>$1</em>');
s = s.replace(/_(.+?)_/g, '<em>$1</em>');
// links
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
return s;
}
// ═══════════════════════════════════════════
// NoteCard — sidebar list item
// ═══════════════════════════════════════════
function NoteCard({ note, active, onClick }) {
var dateStr = note.updated_at || note.created_at || '';
if (dateStr) {
try { dateStr = new Date(dateStr).toLocaleDateString(); } catch(e) { /* keep raw */ }
}
return html`
<div class="note-card ${active ? 'note-card--active' : ''}" onClick=${onClick}>
<div class="note-card__title">
${note.pinned ? html`<span class="note-card__pin">📌</span>` : null}
${note.title || 'Untitled'}
</div>
${note.snippet && html`<div class="note-card__snippet">${note.snippet}</div>`}
${dateStr && html`<div class="note-card__date">${dateStr}</div>`}
</div>
`;
}
// ═══════════════════════════════════════════
// EditorPane — note editor + preview
// ═══════════════════════════════════════════
function EditorPane({ note, onSave, onDelete, onRefresh }) {
var [title, setTitle] = useState(note ? note.title : '');
var [body, setBody] = useState(note ? note.body : '');
var [preview, setPreview] = useState(false);
var [dirty, setDirty] = useState(false);
var [saving, setSaving] = useState(false);
var textareaRef = useRef(null);
// sync when note changes
useEffect(function() {
if (note) {
setTitle(note.title || '');
setBody(note.body || '');
setDirty(false);
setPreview(false);
}
}, [note ? note.id : null]);
function handleTitleChange(e) {
setTitle(e.target.value);
setDirty(true);
debounce('save', function() { doSave(e.target.value, body); }, 1000);
}
function handleBodyChange(e) {
setBody(e.target.value);
setDirty(true);
debounce('save', function() { doSave(title, e.target.value); }, 1000);
}
async function doSave(t, b) {
if (!note) return;
setSaving(true);
try {
await api.put('/notes/' + note.id, { title: t || 'Untitled', body: b });
setDirty(false);
onRefresh();
} catch (e) {
console.error('Save failed:', e);
} finally {
setSaving(false);
}
}
async function handlePin() {
if (!note) return;
await api.put('/notes/' + note.id, { pinned: note.pinned ? 0 : 1 });
onRefresh();
}
async function handleArchive() {
if (!note || !confirm('Archive this note?')) return;
await api.del('/notes/' + note.id);
onDelete();
}
function handleKeyDown(e) {
// Tab inserts two spaces
if (e.key === 'Tab') {
e.preventDefault();
var ta = e.target;
var start = ta.selectionStart;
var end = ta.selectionEnd;
var val = ta.value;
ta.value = val.substring(0, start) + ' ' + val.substring(end);
ta.selectionStart = ta.selectionEnd = start + 2;
setBody(ta.value);
setDirty(true);
debounce('save', function() { doSave(title, ta.value); }, 1000);
}
// Ctrl/Cmd+S force save
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
doSave(title, body);
}
}
var previewHtml = useMemo(function() { return renderMarkdown(body); }, [body]);
if (!note) {
return html`<div class="notes-editor__empty">Select a note or create a new one</div>`;
}
return html`
<div class="notes-editor">
<div class="notes-editor__header">
<input class="notes-editor__title-input" type="text"
value=${title} onInput=${handleTitleChange}
placeholder="Note title" />
<div class="notes-editor__actions">
<span class=${dirty ? 'notes-saved notes-saved--dirty' : 'notes-saved'}>
${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')}
</span>
<button class="notes-toggle ${preview ? 'notes-toggle--active' : ''}"
onClick=${() => setPreview(!preview)}>
${preview ? 'Edit' : 'Preview'}
</button>
<button class="notes-btn" onClick=${handlePin}
title=${note.pinned ? 'Unpin' : 'Pin'}>
${note.pinned ? '📌' : '📍'}
</button>
<button class="notes-btn notes-btn--danger" onClick=${handleArchive}
title="Archive">🗑</button>
</div>
</div>
<div class="notes-editor__body">
${preview
? html`<div class="notes-preview" dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
: html`<textarea class="notes-editor__textarea" ref=${textareaRef}
value=${body} onInput=${handleBodyChange}
onKeyDown=${handleKeyDown}
placeholder="Start writing in Markdown…" />`
}
</div>
</div>
`;
}
// ═══════════════════════════════════════════
// NotesApp — root component
// ═══════════════════════════════════════════
function NotesApp() {
var [notes, setNotes] = useState([]);
var [loading, setLoading] = useState(true);
var [activeId, setActiveId] = useState(null);
var [activeNote, setActiveNote] = useState(null);
var [searchTerm, setSearchTerm] = useState('');
var [stats, setStats] = useState(null);
// ── Load note list ────────────────────────
var loadNotes = useCallback(async function() {
try {
var res;
if (searchTerm.trim()) {
res = await api.get('/search?q=' + encodeURIComponent(searchTerm.trim()));
} else {
res = await api.get('/notes');
}
var items = (res && res.data) || [];
// sort: pinned first, then by updated_at desc
items.sort(function(a, b) {
if (a.pinned !== b.pinned) return b.pinned - a.pinned;
return (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || '');
});
setNotes(items);
} catch (e) {
console.error('Load notes failed:', e);
} finally {
setLoading(false);
}
}, [searchTerm]);
// ── Load single note (full body) ──────────
var loadNote = useCallback(async function(id) {
if (!id) { setActiveNote(null); return; }
try {
var note = await api.get('/notes/' + id);
setActiveNote(note);
} catch (e) {
console.error('Load note failed:', e);
setActiveNote(null);
}
}, []);
// ── Load stats ────────────────────────────
var loadStats = useCallback(async function() {
try {
var s = await api.get('/stats');
setStats(s);
} catch (e) { /* ignore */ }
}, []);
useEffect(function() { loadNotes(); loadStats(); }, [loadNotes]);
// select note
function handleSelect(id) {
setActiveId(id);
loadNote(id);
}
// ── Create note ───────────────────────────
async function handleNew() {
try {
var note = await api.post('/notes', { title: 'Untitled', body: '' });
if (note && note.id) {
setActiveId(note.id);
loadNote(note.id);
loadNotes();
loadStats();
}
} catch (e) {
console.error('Create failed:', e);
}
}
// ── Refresh after save ────────────────────
function handleRefresh() {
loadNotes();
if (activeId) loadNote(activeId);
loadStats();
}
// ── After delete ──────────────────────────
function handleDelete() {
setActiveId(null);
setActiveNote(null);
loadNotes();
loadStats();
}
// ── Search ────────────────────────────────
function handleSearch(e) {
var val = e.target.value;
setSearchTerm(val);
debounce('search', function() { loadNotes(); }, 300);
}
return html`
<${Topbar} title="Notes">
<${Button} variant="primary" size="sm" onClick=${handleNew}>+ New Note<//>
<//>
<div class="notes-app">
<div class="notes-sidebar">
<div class="notes-sidebar__header">
<span class="notes-sidebar__title">Notes</span>
${stats && html`<span class="notes-sidebar__count">${stats.total}</span>`}
</div>
<div class="notes-search">
<input type="text" placeholder="Search notes…"
value=${searchTerm} onInput=${handleSearch} />
</div>
<div class="notes-list">
${loading && html`<div class="notes-list__loading"><${Spinner} size="md" /></div>`}
${!loading && notes.length === 0 && html`
<div class="notes-list__empty">
${searchTerm ? 'No matching notes' : 'No notes yet. Click + New Note to start.'}
</div>
`}
${!loading && notes.map(function(n) {
return html`
<${NoteCard} key=${n.id} note=${n}
active=${n.id === activeId}
onClick=${() => handleSelect(n.id)} />
`;
})}
</div>
</div>
<${EditorPane} note=${activeNote}
onSave=${handleRefresh}
onDelete=${handleDelete}
onRefresh=${handleRefresh} />
</div>
`;
}
// ── Mount ──────────────────────────────────
render(html`<${NotesApp} />`, mount);
})();

View File

@@ -0,0 +1,54 @@
{
"id": "notes",
"title": "Notes",
"type": "full",
"tier": "starlark",
"route": "/s/notes",
"auth": "authenticated",
"layout": "single",
"version": "0.1.0",
"icon": "📝",
"description": "Markdown notes surface with folders, tags, and backlinks.",
"author": "switchboard",
"permissions": ["db.write"],
"api_routes": [
{"method": "GET", "path": "/notes"},
{"method": "POST", "path": "/notes"},
{"method": "GET", "path": "/notes/*"},
{"method": "PUT", "path": "/notes/*"},
{"method": "DELETE", "path": "/notes/*"},
{"method": "GET", "path": "/search"},
{"method": "GET", "path": "/stats"}
],
"db_tables": {
"notes": {
"columns": {
"title": "text",
"body": "text",
"folder_id": "text",
"creator_id": "text",
"updated_at": "text",
"pinned": "int",
"archived": "int"
},
"indexes": [
["folder_id"],
["creator_id"],
["pinned"],
["updated_at"]
]
}
},
"settings": {
"default_view": {
"type": "string",
"label": "Default View",
"description": "Default note list view: recent or pinned",
"default": "recent"
}
}
}

251
packages/notes/script.star Normal file
View File

@@ -0,0 +1,251 @@
# Notes — Starlark Backend (v0.1.0)
#
# Markdown notes surface using ext_data.
#
# Entry points:
# on_request(req) → surface API routes
#
# Modules: db, json, settings
# ═══════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════
def _resp(status, data):
return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
def _str(v):
if v == None:
return ""
return str(v)
def _int(v):
if v == None:
return 0
s = str(v)
if not s:
return 0
return int(s)
def _snippet(body, max_len = 120):
"""First max_len chars of body, stripped of leading #/whitespace."""
if not body:
return ""
line = body.split("\n")[0]
# strip leading markdown heading markers
line = line.lstrip("#").strip()
if len(line) > max_len:
return line[:max_len]
return line
# ═══════════════════════════════════════════════
# Surface API routes (/s/notes/api/*)
# ═══════════════════════════════════════════════
def on_request(req):
path = req["path"]
method = req["method"]
# GET /notes — list
if method == "GET" and path == "/notes":
return _list_notes(req)
# POST /notes — create
if method == "POST" and path == "/notes":
return _create_note(req)
# GET /stats
if method == "GET" and path == "/stats":
return _get_stats()
# GET /search
if method == "GET" and path == "/search":
return _search_notes(req)
# GET /notes/:id
if method == "GET" and path.startswith("/notes/"):
return _get_note(path[len("/notes/"):])
# PUT /notes/:id
if method == "PUT" and path.startswith("/notes/"):
return _update_note(path[len("/notes/"):], req)
# DELETE /notes/:id
if method == "DELETE" and path.startswith("/notes/"):
return _delete_note(path[len("/notes/"):], req)
return _resp(404, {"error": "not found"})
# ═══════════════════════════════════════════════
# CRUD
# ═══════════════════════════════════════════════
def _list_notes(req):
q = req.get("query", {})
filters = {}
folder = _str(q.get("folder_id", ""))
if folder:
filters["folder_id"] = folder
pinned = _str(q.get("pinned", ""))
if pinned:
filters["pinned"] = int(pinned)
archived = _str(q.get("archived", ""))
if archived:
filters["archived"] = int(archived)
else:
# default: exclude archived
filters["archived"] = 0
creator = _str(q.get("creator_id", ""))
if creator:
filters["creator_id"] = creator
order = _str(q.get("order", "")) or "updated_at"
limit_str = _str(q.get("limit", ""))
limit = int(limit_str) if limit_str else 200
rows = db.query("notes", filters=filters, order=order, limit=limit)
# lightweight projection: strip body for list view
items = []
for r in (rows or []):
items.append({
"id": r.get("id", ""),
"title": r.get("title", ""),
"snippet": _snippet(r.get("body", "")),
"folder_id": r.get("folder_id", ""),
"creator_id": r.get("creator_id", ""),
"updated_at": r.get("updated_at", ""),
"pinned": _int(r.get("pinned", 0)),
"archived": _int(r.get("archived", 0)),
"created_at": r.get("created_at", ""),
})
return _resp(200, {"data": items})
def _create_note(req):
body = json.decode(req.get("body", "{}"))
user_id = req.get("user_id", "")
title = _str(body.get("title", ""))
if not title:
title = "Untitled"
row = db.insert("notes", {
"title": title,
"body": _str(body.get("body", "")),
"folder_id": _str(body.get("folder_id", "")),
"creator_id": user_id,
"updated_at": "",
"pinned": _int(body.get("pinned", 0)),
"archived": 0,
})
return _resp(201, row)
def _get_note(note_id):
rows = db.query("notes", filters={"id": note_id}, limit=1)
if not rows:
return _resp(404, {"error": "note not found"})
return _resp(200, rows[0])
def _update_note(note_id, req):
body = json.decode(req.get("body", "{}"))
existing = db.query("notes", filters={"id": note_id}, limit=1)
if not existing:
return _resp(404, {"error": "note not found"})
updates = {}
for key in ["title", "body", "folder_id"]:
if key in body:
updates[key] = _str(body[key])
if "pinned" in body:
updates["pinned"] = _int(body["pinned"])
if "archived" in body:
updates["archived"] = _int(body["archived"])
# touch updated_at
updates["updated_at"] = "now"
ok = db.update("notes", note_id, updates)
if not ok:
return _resp(500, {"error": "update failed"})
rows = db.query("notes", filters={"id": note_id}, limit=1)
return _resp(200, rows[0] if rows else {})
def _delete_note(note_id, req):
q = req.get("query", {})
hard = _str(q.get("hard", ""))
if hard == "1":
ok = db.delete("notes", note_id)
if not ok:
return _resp(404, {"error": "note not found"})
return _resp(200, {"deleted": True})
# soft delete: archive
ok = db.update("notes", note_id, {"archived": 1, "updated_at": "now"})
if not ok:
return _resp(404, {"error": "note not found"})
return _resp(200, {"archived": True})
# ═══════════════════════════════════════════════
# Search
# ═══════════════════════════════════════════════
def _search_notes(req):
q = req.get("query", {})
term = _str(q.get("q", ""))
if not term:
return _resp(400, {"error": "q parameter required"})
# fetch all non-archived notes and filter in Starlark
# (ext_data db.query doesn't support LIKE — filter client-side)
rows = db.query("notes", filters={"archived": 0}, order="updated_at", limit=1000)
term_lower = term.lower()
matches = []
for r in (rows or []):
title = _str(r.get("title", "")).lower()
body = _str(r.get("body", "")).lower()
if term_lower in title or term_lower in body:
matches.append({
"id": r.get("id", ""),
"title": r.get("title", ""),
"snippet": _snippet(r.get("body", "")),
"folder_id": r.get("folder_id", ""),
"updated_at": r.get("updated_at", ""),
"pinned": _int(r.get("pinned", 0)),
"created_at": r.get("created_at", ""),
})
return _resp(200, {"data": matches})
# ═══════════════════════════════════════════════
# Stats
# ═══════════════════════════════════════════════
def _get_stats():
all_notes = db.query("notes", limit=10000)
notes = all_notes or []
total = 0
pinned = 0
archived = 0
for n in notes:
if _int(n.get("archived", 0)) == 1:
archived = archived + 1
else:
total = total + 1
if _int(n.get("pinned", 0)) == 1:
pinned = pinned + 1
return _resp(200, {"total": total, "pinned": pinned, "archived": archived})