From 70404ff070dcfb23a1ae426b6d43f34b011f48b0 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Sun, 29 Mar 2026 11:38:54 +0000 Subject: [PATCH 1/3] Feat v0.4.1 folders navigation tree (#23) Add folders as a first-class concept in the Notes surface with full CRUD, nested tree navigation, and folder-based note filtering. Backend (script.star): - Folder CRUD handlers: list, create, update, delete - Move-note endpoint to reassign folder_id - Delete cascades: orphan notes to unfiled, reparent child folders - Stats now include unfiled and folder counts Frontend (js/main.js): - FolderTree component with flat-to-tree builder via useMemo - FolderNode recursive component with expand/collapse, inline rename - Folder state: activeFolderId, showUnfiled for sidebar filtering - Editor folder select dropdown for moving notes between folders - New notes inherit active folder on creation Schema (manifest.json): - New ext_notes_folders table (name, parent_id, creator_id, sort_order) - 5 new API routes: GET/POST /folders, PUT/DELETE /folders/*, POST /notes/move - Version bumped to 0.2.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/notes/css/main.css | 87 ++++++++++++++ packages/notes/js/main.js | 223 +++++++++++++++++++++++++++++++++-- packages/notes/manifest.json | 21 +++- packages/notes/script.star | 126 +++++++++++++++++++- 4 files changed, 441 insertions(+), 16 deletions(-) diff --git a/packages/notes/css/main.css b/packages/notes/css/main.css index 7d7602a..3c88db6 100644 --- a/packages/notes/css/main.css +++ b/packages/notes/css/main.css @@ -39,6 +39,75 @@ gap: 4px; } +/* ── Folder Tree ────────────────────────── */ +.folder-tree { + padding: 4px 0; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + max-height: 40%; + overflow-y: auto; +} +.folder-tree__item { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 12px; + font-size: 13px; + color: var(--text-2); + cursor: pointer; + border-radius: var(--radius); + margin: 1px 6px; + transition: var(--transition); + user-select: none; +} +.folder-tree__item:hover { + background: var(--bg-hover); + color: var(--text); +} +.folder-tree__item--active { + background: var(--bg-active); + color: var(--text); + font-weight: 500; +} +.folder-tree__toggle { + width: 14px; + font-size: 9px; + text-align: center; + flex-shrink: 0; + color: var(--text-3); +} +.folder-tree__icon { + font-size: 13px; + flex-shrink: 0; +} +.folder-tree__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.folder-tree__edit { + flex: 1; + font-size: 13px; + padding: 1px 4px; + border: 1px solid var(--accent); + border-radius: 3px; + background: var(--bg-surface); + color: var(--text); + font-family: var(--font); + outline: none; +} +.folder-tree__add { + padding: 6px 12px; + font-size: 12px; + color: var(--text-3); + cursor: pointer; + transition: var(--transition); +} +.folder-tree__add:hover { + color: var(--accent); +} + /* ── Search ──────────────────────────────── */ .notes-search { padding: 8px 14px; @@ -171,6 +240,23 @@ align-items: center; } +/* ── Folder select in editor ────────────── */ +.notes-editor__folder-select { + font-size: 12px; + padding: 3px 6px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-raised); + color: var(--text-2); + font-family: var(--font); + cursor: pointer; + max-width: 140px; +} +.notes-editor__folder-select:focus { + outline: none; + border-color: var(--accent); +} + /* ── Editor Body ─────────────────────────── */ .notes-editor__body { flex: 1; @@ -291,4 +377,5 @@ @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; } + .folder-tree { max-height: 30vh; } } diff --git a/packages/notes/js/main.js b/packages/notes/js/main.js index b0341c5..5f4933f 100644 --- a/packages/notes/js/main.js +++ b/packages/notes/js/main.js @@ -1,5 +1,5 @@ /** - * Notes — Surface Entry Point (v0.1.0) + * Notes — Surface Entry Point (v0.2.0) * * Markdown notes surface using the SDK: * sw.api.ext('notes') — scoped API client @@ -164,11 +164,132 @@ } + // ═══════════════════════════════════════════ + // FolderNode — recursive tree node + // ═══════════════════════════════════════════ + + function FolderNode({ node, depth, activeFolderId, onSelect, onRename, onDelete, onCreateSub }) { + var [expanded, setExpanded] = useState(true); + var [editing, setEditing] = useState(false); + var [editName, setEditName] = useState(node.folder.name); + var hasChildren = node.children.length > 0; + var isActive = activeFolderId === node.folder.id; + var indent = 20 + depth * 16; + + function handleToggle(e) { + e.stopPropagation(); + setExpanded(!expanded); + } + + function handleRename() { + if (editName.trim() && editName !== node.folder.name) { + onRename(node.folder.id, editName.trim()); + } + setEditing(false); + } + + function handleContext(e) { + e.preventDefault(); + var action = prompt('Type "rename" to rename, "delete" to delete, "subfolder" to add subfolder'); + if (action === 'rename') { setEditing(true); setEditName(node.folder.name); } + else if (action === 'delete') { onDelete(node.folder.id); } + else if (action === 'subfolder') { onCreateSub(node.folder.id); } + } + + return html` +
+
+ + ${hasChildren ? (expanded ? '▼' : '▶') : '·'} + + ${isActive ? '📂' : '📁'} + ${editing + ? html`` + : html`${node.folder.name}` + } +
+ ${expanded && node.children.map(function(child) { + return html`<${FolderNode} key=${child.folder.id} node=${child} depth=${depth + 1} + activeFolderId=${activeFolderId} + onSelect=${onSelect} onRename=${onRename} + onDelete=${onDelete} onCreateSub=${onCreateSub} />`; + })} +
+ `; + } + + + // ═══════════════════════════════════════════ + // FolderTree — navigation panel + // ═══════════════════════════════════════════ + + function FolderTree({ folders, activeFolderId, showUnfiled, onSelectFolder, onSelectAll, onSelectUnfiled, onCreateFolder, onRenameFolder, onDeleteFolder }) { + // build tree from flat list + var tree = useMemo(function() { + var byId = {}; + var roots = []; + for (var i = 0; i < folders.length; i++) { + byId[folders[i].id] = { folder: folders[i], children: [] }; + } + for (var i = 0; i < folders.length; i++) { + var f = folders[i]; + var node = byId[f.id]; + if (f.parent_id && byId[f.parent_id]) { + byId[f.parent_id].children.push(node); + } else { + roots.push(node); + } + } + function sortNodes(arr) { + arr.sort(function(a, b) { + if (a.folder.sort_order !== b.folder.sort_order) return a.folder.sort_order - b.folder.sort_order; + return (a.folder.name || '').localeCompare(b.folder.name || ''); + }); + for (var j = 0; j < arr.length; j++) sortNodes(arr[j].children); + } + sortNodes(roots); + return roots; + }, [folders]); + + return html` +
+
+ 📋 + All Notes +
+
+ 📄 + Unfiled +
+ ${tree.map(function(node) { + return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0} + activeFolderId=${activeFolderId} + onSelect=${onSelectFolder} onRename=${onRenameFolder} + onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder} />`; + })} +
+ + New Folder +
+
+ `; + } + + // ═══════════════════════════════════════════ // EditorPane — note editor + preview // ═══════════════════════════════════════════ - function EditorPane({ note, onSave, onDelete, onRefresh }) { + function EditorPane({ note, folders, onSave, onDelete, onRefresh }) { var [title, setTitle] = useState(note ? note.title : ''); var [body, setBody] = useState(note ? note.body : ''); var [preview, setPreview] = useState(false); @@ -258,6 +379,17 @@ value=${title} onInput=${handleTitleChange} placeholder="Note title" />
+ ${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')} @@ -292,12 +424,15 @@ // ═══════════════════════════════════════════ 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); + 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); + var [folders, setFolders] = useState([]); + var [activeFolderId, setActiveFolderId] = useState(null); + var [showUnfiled, setShowUnfiled] = useState(false); // ── Load note list ──────────────────────── var loadNotes = useCallback(async function() { @@ -305,11 +440,17 @@ var res; if (searchTerm.trim()) { res = await api.get('/search?q=' + encodeURIComponent(searchTerm.trim())); + } else if (activeFolderId) { + res = await api.get('/notes?folder_id=' + encodeURIComponent(activeFolderId)); } else { res = await api.get('/notes'); } var items = (res && res.data) || res || []; if (!Array.isArray(items)) items = []; + // filter to unfiled when that view is active + if (showUnfiled && !searchTerm.trim() && !activeFolderId) { + items = items.filter(function(n) { return !n.folder_id; }); + } // sort: pinned first, then by updated_at desc items.sort(function(a, b) { if (a.pinned !== b.pinned) return b.pinned - a.pinned; @@ -321,7 +462,7 @@ } finally { setLoading(false); } - }, [searchTerm]); + }, [searchTerm, activeFolderId, showUnfiled]); // ── Load single note (full body) ────────── var loadNote = useCallback(async function(id) { @@ -335,6 +476,15 @@ } }, []); + // ── Load folders ────────────────────────── + var loadFolders = useCallback(async function() { + try { + var res = await api.get('/folders'); + var items = (res && res.data) || res || []; + setFolders(Array.isArray(items) ? items : []); + } catch (e) { console.error('Load folders failed:', e); } + }, []); + // ── Load stats ──────────────────────────── var loadStats = useCallback(async function() { try { @@ -343,7 +493,7 @@ } catch (e) { /* ignore */ } }, []); - useEffect(function() { loadNotes(); loadStats(); }, [loadNotes]); + useEffect(function() { loadNotes(); loadFolders(); loadStats(); }, [loadNotes]); // select note function handleSelect(id) { @@ -354,7 +504,9 @@ // ── Create note ─────────────────────────── async function handleNew() { try { - var note = await api.post('/notes', { title: 'Untitled', body: '' }); + var payload = { title: 'Untitled', body: '' }; + if (activeFolderId) payload.folder_id = activeFolderId; + var note = await api.post('/notes', payload); if (note && note.id) { setActiveId(note.id); loadNote(note.id); @@ -381,6 +533,46 @@ loadStats(); } + // ── Folder actions ─────────────────────── + function handleSelectFolder(folderId) { + setActiveFolderId(folderId); + setShowUnfiled(false); + setSearchTerm(''); + } + function handleSelectAll() { + setActiveFolderId(null); + setShowUnfiled(false); + } + function handleSelectUnfiled() { + setActiveFolderId(null); + setShowUnfiled(true); + } + async function handleCreateFolder(parentId) { + var name = prompt('Folder name:'); + if (!name || !name.trim()) return; + try { + await api.post('/folders', { name: name.trim(), parent_id: parentId || '' }); + loadFolders(); + loadStats(); + } catch (e) { console.error('Create folder failed:', e); } + } + async function handleRenameFolder(folderId, newName) { + try { + await api.put('/folders/' + folderId, { name: newName }); + loadFolders(); + } catch (e) { console.error('Rename folder failed:', e); } + } + async function handleDeleteFolder(folderId) { + if (!confirm('Delete this folder? Notes will be moved to Unfiled.')) return; + try { + await api.del('/folders/' + folderId); + if (activeFolderId === folderId) setActiveFolderId(null); + loadFolders(); + loadNotes(); + loadStats(); + } catch (e) { console.error('Delete folder failed:', e); } + } + // ── Search ──────────────────────────────── function handleSearch(e) { var val = e.target.value; @@ -398,6 +590,13 @@ Notes ${stats && html`${stats.total}`}
+ <${FolderTree} folders=${folders} + activeFolderId=${activeFolderId} showUnfiled=${showUnfiled} + onSelectFolder=${handleSelectFolder} onSelectAll=${handleSelectAll} + onSelectUnfiled=${handleSelectUnfiled} + onCreateFolder=${handleCreateFolder} + onRenameFolder=${handleRenameFolder} + onDeleteFolder=${handleDeleteFolder} /> - <${EditorPane} note=${activeNote} + <${EditorPane} note=${activeNote} folders=${folders} onSave=${handleRefresh} onDelete=${handleDelete} onRefresh=${handleRefresh} /> diff --git a/packages/notes/manifest.json b/packages/notes/manifest.json index 96c66a2..8056d9d 100644 --- a/packages/notes/manifest.json +++ b/packages/notes/manifest.json @@ -6,7 +6,7 @@ "route": "/s/notes", "auth": "authenticated", "layout": "single", - "version": "0.1.0", + "version": "0.2.0", "icon": "📝", "description": "Markdown notes surface with folders, tags, and backlinks.", "author": "switchboard", @@ -20,7 +20,12 @@ {"method": "PUT", "path": "/notes/*"}, {"method": "DELETE", "path": "/notes/*"}, {"method": "GET", "path": "/search"}, - {"method": "GET", "path": "/stats"} + {"method": "GET", "path": "/stats"}, + {"method": "GET", "path": "/folders"}, + {"method": "POST", "path": "/folders"}, + {"method": "PUT", "path": "/folders/*"}, + {"method": "DELETE", "path": "/folders/*"}, + {"method": "POST", "path": "/notes/move"} ], "db_tables": { @@ -40,6 +45,18 @@ ["pinned"], ["updated_at"] ] + }, + "folders": { + "columns": { + "name": "text", + "parent_id": "text", + "creator_id": "text", + "sort_order": "int" + }, + "indexes": [ + ["parent_id"], + ["creator_id"] + ] } }, diff --git a/packages/notes/script.star b/packages/notes/script.star index 4bc1aa4..6aaf53e 100644 --- a/packages/notes/script.star +++ b/packages/notes/script.star @@ -1,4 +1,4 @@ -# Notes — Starlark Backend (v0.1.0) +# Notes — Starlark Backend (v0.2.0) # # Markdown notes surface using ext_data. # @@ -76,6 +76,27 @@ def on_request(req): if method == "DELETE" and path.startswith("/notes/"): return _delete_note(path[len("/notes/"):], req) + # POST /notes/move + if method == "POST" and path == "/notes/move": + return _move_note(req) + + # ── Folder routes ────────────────────────── + # GET /folders + if method == "GET" and path == "/folders": + return _list_folders() + + # POST /folders + if method == "POST" and path == "/folders": + return _create_folder(req) + + # PUT /folders/:id + if method == "PUT" and path.startswith("/folders/"): + return _update_folder(path[len("/folders/"):], req) + + # DELETE /folders/:id + if method == "DELETE" and path.startswith("/folders/"): + return _delete_folder(path[len("/folders/"):]) + return _resp(404, {"error": "not found"}) @@ -200,6 +221,102 @@ def _delete_note(note_id, req): return _resp(200, {"archived": True}) +# ═══════════════════════════════════════════════ +# Move note between folders +# ═══════════════════════════════════════════════ + +def _move_note(req): + body = json.decode(req.get("body", "{}")) + note_id = _str(body.get("note_id", "")) + folder_id = _str(body.get("folder_id", "")) + if not note_id: + return _resp(400, {"error": "note_id required"}) + existing = db.query("notes", filters={"id": note_id}, limit=1) + if not existing: + return _resp(404, {"error": "note not found"}) + ok = db.update("notes", note_id, {"folder_id": folder_id, "updated_at": "now"}) + if not ok: + return _resp(500, {"error": "move failed"}) + rows = db.query("notes", filters={"id": note_id}, limit=1) + return _resp(200, rows[0] if rows else {}) + + +# ═══════════════════════════════════════════════ +# Folder CRUD +# ═══════════════════════════════════════════════ + +def _list_folders(): + rows = db.query("folders", order="sort_order", limit=500) + items = [] + for r in (rows or []): + items.append({ + "id": r.get("id", ""), + "name": r.get("name", ""), + "parent_id": r.get("parent_id", ""), + "sort_order": _int(r.get("sort_order", 0)), + "creator_id": r.get("creator_id", ""), + "created_at": r.get("created_at", ""), + }) + return _resp(200, {"data": items}) + + +def _create_folder(req): + body = json.decode(req.get("body", "{}")) + user_id = req.get("user_id", "") + name = _str(body.get("name", "")) + if not name: + return _resp(400, {"error": "name required"}) + row = db.insert("folders", { + "name": name, + "parent_id": _str(body.get("parent_id", "")), + "creator_id": user_id, + "sort_order": _int(body.get("sort_order", 0)), + }) + return _resp(201, row) + + +def _update_folder(folder_id, req): + body = json.decode(req.get("body", "{}")) + existing = db.query("folders", filters={"id": folder_id}, limit=1) + if not existing: + return _resp(404, {"error": "folder not found"}) + updates = {} + for key in ["name", "parent_id"]: + if key in body: + updates[key] = _str(body[key]) + if "sort_order" in body: + updates["sort_order"] = _int(body["sort_order"]) + if not updates: + return _resp(200, existing[0]) + ok = db.update("folders", folder_id, updates) + if not ok: + return _resp(500, {"error": "update failed"}) + rows = db.query("folders", filters={"id": folder_id}, limit=1) + return _resp(200, rows[0] if rows else {}) + + +def _delete_folder(folder_id): + existing = db.query("folders", filters={"id": folder_id}, limit=1) + if not existing: + return _resp(404, {"error": "folder not found"}) + parent_id = _str(existing[0].get("parent_id", "")) + + # reparent child folders to deleted folder's parent + children = db.query("folders", filters={"parent_id": folder_id}, limit=500) + for child in (children or []): + db.update("folders", child["id"], {"parent_id": parent_id}) + + # orphan notes in this folder (move to unfiled) + notes_in_folder = db.query("notes", filters={"folder_id": folder_id}, limit=1000) + for n in (notes_in_folder or []): + db.update("notes", n["id"], {"folder_id": ""}) + + ok = db.delete("folders", folder_id) + if not ok: + return _resp(500, {"error": "delete failed"}) + return _resp(200, {"deleted": True, "orphaned_notes": len(notes_in_folder or [])}) + + # ═══════════════════════════════════════════════ # Search # ═══════════════════════════════════════════════ @@ -241,6 +358,7 @@ def _get_stats(): total = 0 pinned = 0 archived = 0 + unfiled = 0 for n in notes: if _int(n.get("archived", 0)) == 1: archived = archived + 1 @@ -248,4 +366,8 @@ def _get_stats(): total = total + 1 if _int(n.get("pinned", 0)) == 1: pinned = pinned + 1 - return _resp(200, {"total": total, "pinned": pinned, "archived": archived}) + if not _str(n.get("folder_id", "")): + unfiled = unfiled + 1 + all_folders = db.query("folders", limit=10000) + folder_count = len(all_folders or []) + return _resp(200, {"total": total, "pinned": pinned, "archived": archived, "unfiled": unfiled, "folders": folder_count}) -- 2.49.1 From 38938c2961918960da22b0dfa06d5ff75fc7e05f Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Sun, 29 Mar 2026 11:58:42 +0000 Subject: [PATCH 2/3] Fix folder dropdown to show nested hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor folder select was a flat list making it impossible to distinguish subfolder nesting. Now builds a tree and renders with └ prefix indentation (e.g. " └ Projects" under "Work"). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/notes/js/main.js | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/notes/js/main.js b/packages/notes/js/main.js index 5f4933f..f7f0cd7 100644 --- a/packages/notes/js/main.js +++ b/packages/notes/js/main.js @@ -368,6 +368,40 @@ var previewHtml = useMemo(function() { return renderMarkdown(body); }, [body]); + // build indented folder list for the select dropdown + var folderOptions = useMemo(function() { + var list = folders || []; + var byId = {}; + var roots = []; + for (var i = 0; i < list.length; i++) { + byId[list[i].id] = { folder: list[i], children: [] }; + } + for (var i = 0; i < list.length; i++) { + var f = list[i]; + if (f.parent_id && byId[f.parent_id]) { + byId[f.parent_id].children.push(byId[f.id]); + } else { + roots.push(byId[f.id]); + } + } + var flat = []; + function walk(nodes, depth) { + nodes.sort(function(a, b) { + if (a.folder.sort_order !== b.folder.sort_order) return a.folder.sort_order - b.folder.sort_order; + return (a.folder.name || '').localeCompare(b.folder.name || ''); + }); + for (var j = 0; j < nodes.length; j++) { + var prefix = ''; + for (var k = 0; k < depth; k++) prefix += '\u00A0\u00A0'; + if (depth > 0) prefix += '\u2514 '; + flat.push({ id: nodes[j].folder.id, label: prefix + nodes[j].folder.name }); + walk(nodes[j].children, depth + 1); + } + } + walk(roots, 0); + return flat; + }, [folders]); + if (!note) { return html`
Select a note or create a new one
`; } @@ -386,8 +420,8 @@ onRefresh(); }}> - ${(folders || []).map(function(f) { - return html``; + ${folderOptions.map(function(f) { + return html``; })} -- 2.49.1 From cb8238baf7855982df2986f0fd7425753806507b Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Sun, 29 Mar 2026 12:07:06 +0000 Subject: [PATCH 3/3] Update version, changelog, and roadmap for v0.4.1 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ ROADMAP.md | 17 +++++++++++------ VERSION | 2 +- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e4bb0b..28375f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to Switchboard Core are documented here. +## v0.4.1 — Folders + Navigation Tree + +### Added + +- **Folders table** (`ext_notes_folders`): name, parent_id, creator_id, + sort_order. Indexed on parent_id and creator_id. Nested hierarchy via + parent_id foreign key (empty string = root). +- **Folder CRUD API**: 5 new Starlark handlers — list, create, update, delete + folders plus a dedicated move-note endpoint (`POST /notes/move`). +- **Navigation tree**: `FolderTree` + `FolderNode` Preact components in the + sidebar. Flat API response built into a nested tree client-side via `useMemo`. + Expand/collapse toggles, depth-based indentation, inline rename via + right-click context menu. +- **Folder filtering**: Clicking a folder filters notes by `folder_id` query + param. "All Notes" clears the filter. "Unfiled" shows notes with empty + folder_id (client-side filter). +- **Editor folder select**: Dropdown in the editor header moves notes between + folders. Shows nested hierarchy with `└` prefix indentation. +- **Folder-aware note creation**: New notes inherit the active folder's ID. +- **Delete cascade**: Deleting a folder orphans its notes (moves to Unfiled) + and reparents child folders to the deleted folder's parent. +- **Updated stats**: `/stats` response now includes `unfiled` and `folders` + counts. + +### Data model + +| Table | Columns | +|-------|---------| +| `ext_notes_folders` | name, parent_id, creator_id, sort_order | + +### Notes package version + +Bumped from 0.1.0 → 0.2.0. 12 API routes (7 existing + 5 new). + ## v0.4.0 — Notes Surface ### Added diff --git a/ROADMAP.md b/ROADMAP.md index abba0df..c00ba26 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Switchboard Core — Roadmap -## Current: v0.4.0 — Notes Surface +## Current: v0.4.1 — Folders + Navigation Tree Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat features removed from the kernel. What remains is the minimum viable @@ -242,18 +242,23 @@ Zero platform special-casing. Proves the full extension stack E2E. | 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) +### v0.4.1 — Folders + Navigation Tree (complete) -- `folders` table (name, parent_id, sort_order) -- Folder CRUD API (4 routes) + note move -- Collapsible folder tree in sidebar -- Breadcrumb trail in editor +| Step | Status | Description | +|------|--------|-------------| +| Folders table | ✅ | `ext_notes_folders` — name, parent_id, creator_id, sort_order. Indexed on parent_id and creator_id. | +| Folder CRUD API | ✅ | 5 Starlark handlers: list, create, update, delete folders + `POST /notes/move`. Delete cascade orphans notes and reparents child folders. | +| Folder tree UI | ✅ | `FolderTree` + `FolderNode` components. Flat-to-tree builder, expand/collapse, depth indentation, inline rename via context menu. | +| Folder filtering | ✅ | Click folder → filter notes. "All Notes" / "Unfiled" virtual views. New notes inherit active folder. | +| Editor folder select | ✅ | Dropdown with nested hierarchy (`└` prefix). Moves notes between folders via move-note API. | +| Updated stats | ✅ | Stats include `unfiled` and `folders` counts. | ### 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 +- Drag-and-drop note-to-folder moves ### v0.4.3 — Backlinks + Wikilinks (planned) diff --git a/VERSION b/VERSION index 1d0ba9e..267577d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.4.1 -- 2.49.1