Feat v0.4.0 notes surface #22
45
ROADMAP.md
45
ROADMAP.md
@@ -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
52
packages/notes/README.md
Normal 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
294
packages/notes/css/main.css
Normal 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
431
packages/notes/js/main.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
})();
|
||||
54
packages/notes/manifest.json
Normal file
54
packages/notes/manifest.json
Normal 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
251
packages/notes/script.star
Normal 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})
|
||||
Reference in New Issue
Block a user