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 @@
${stats && html``}
+ <${FolderTree} folders=${folders}
+ activeFolderId=${activeFolderId} showUnfiled=${showUnfiled}
+ onSelectFolder=${handleSelectFolder} onSelectAll=${handleSelectAll}
+ onSelectUnfiled=${handleSelectUnfiled}
+ onCreateFolder=${handleCreateFolder}
+ onRenameFolder=${handleRenameFolder}
+ onDeleteFolder=${handleDeleteFolder} />
@@ -418,7 +617,7 @@
})}
- <${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})