diff --git a/ROADMAP.md b/ROADMAP.md index 74c5fc7..abba0df 100644 --- a/ROADMAP.md +++ b/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 diff --git a/packages/notes/README.md b/packages/notes/README.md new file mode 100644 index 0000000..f67bb1d --- /dev/null +++ b/packages/notes/README.md @@ -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 diff --git a/packages/notes/css/main.css b/packages/notes/css/main.css new file mode 100644 index 0000000..7d7602a --- /dev/null +++ b/packages/notes/css/main.css @@ -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; } +} diff --git a/packages/notes/js/main.js b/packages/notes/js/main.js new file mode 100644 index 0000000..66148fe --- /dev/null +++ b/packages/notes/js/main.js @@ -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 = '
SDK boot failed: ' + e.message + '
'; + 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(''); + inCode = false; + } else { + if (inList) { out.push('' + listTag + '>'); inList = false; } + out.push('');
+ 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('' + inline(line.slice(4)) + '
'); continue; }
+ if (line.startsWith('## ')) { closeList(); out.push('' + inline(line.slice(3)) + '
'); continue; }
+ if (line.startsWith('# ')) { closeList(); out.push('' + inline(line.slice(2)) + '
'); continue; }
+
+ // hr
+ if (/^[-*_]{3,}\s*$/.test(line)) { closeList(); out.push('
'); continue; }
+
+ // blockquote
+ if (line.startsWith('> ')) { closeList(); out.push('' + inline(line.slice(2)) + '
'); continue; }
+
+ // unordered list
+ if (/^[\-*+]\s/.test(line)) {
+ if (!inList || listTag !== 'ul') { closeList(); out.push(''); inList = true; listTag = 'ul'; }
+ out.push('- ' + inline(line.replace(/^[\-*+]\s/, '')) + '
');
+ continue;
+ }
+
+ // ordered list
+ if (/^\d+\.\s/.test(line)) {
+ if (!inList || listTag !== 'ol') { closeList(); out.push(''); inList = true; listTag = 'ol'; }
+ out.push('- ' + inline(line.replace(/^\d+\.\s/, '')) + '
');
+ continue;
+ }
+
+ // paragraph
+ closeList();
+ out.push('' + inline(line) + '
');
+ }
+ if (inCode) out.push('
');
+ 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, '>');
+ }
+
+ function inline(s) {
+ s = escHtml(s);
+ // inline code
+ s = s.replace(/`([^`]+)`/g, '$1');
+ // bold
+ s = s.replace(/\*\*(.+?)\*\*/g, '$1');
+ s = s.replace(/__(.+?)__/g, '$1');
+ // italic
+ s = s.replace(/\*(.+?)\*/g, '$1');
+ s = s.replace(/_(.+?)_/g, '$1');
+ // links
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
+ 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`
+