Feat v0.4.1 folders (#23)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m38s
CI/CD / test-sqlite (push) Successful in 2m48s
CI/CD / build-and-deploy (push) Successful in 28s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #23.
This commit is contained in:
2026-03-29 12:23:47 +00:00
committed by xcaliber
parent 2c8dc59284
commit 03c182b9d1
7 changed files with 521 additions and 23 deletions

View File

@@ -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

View File

@@ -1,6 +1,6 @@
# Switchboard Core — Roadmap
## Current: v0.4.0Notes Surface
## Current: v0.4.1Folders + 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)

View File

@@ -1 +1 @@
0.4.0
0.4.1

View File

@@ -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; }
}

View File

@@ -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`
<div>
<div class="folder-tree__item ${isActive ? 'folder-tree__item--active' : ''}"
style="padding-left: ${indent}px"
onClick=${function() { onSelect(node.folder.id); }}
onContextMenu=${handleContext}>
<span class="folder-tree__toggle" onClick=${handleToggle}>
${hasChildren ? (expanded ? '▼' : '▶') : '·'}
</span>
<span class="folder-tree__icon">${isActive ? '📂' : '📁'}</span>
${editing
? html`<input class="folder-tree__edit" value=${editName}
onInput=${function(e) { setEditName(e.target.value); }}
onBlur=${handleRename}
onKeyDown=${function(e) { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setEditing(false); }}
onClick=${function(e) { e.stopPropagation(); }}
ref=${function(el) { if (el) el.focus(); }} />`
: html`<span class="folder-tree__name">${node.folder.name}</span>`
}
</div>
${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} />`;
})}
</div>
`;
}
// ═══════════════════════════════════════════
// 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`
<div class="folder-tree">
<div class="folder-tree__item ${!activeFolderId && !showUnfiled ? 'folder-tree__item--active' : ''}"
onClick=${onSelectAll}>
<span class="folder-tree__icon">📋</span>
<span class="folder-tree__name">All Notes</span>
</div>
<div class="folder-tree__item ${showUnfiled ? 'folder-tree__item--active' : ''}"
onClick=${onSelectUnfiled}>
<span class="folder-tree__icon">📄</span>
<span class="folder-tree__name">Unfiled</span>
</div>
${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} />`;
})}
<div class="folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
+ New Folder
</div>
</div>
`;
}
// ═══════════════════════════════════════════
// 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);
@@ -247,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`<div class="notes-editor__empty">Select a note or create a new one</div>`;
}
@@ -258,6 +413,17 @@
value=${title} onInput=${handleTitleChange}
placeholder="Note title" />
<div class="notes-editor__actions">
<select class="notes-editor__folder-select"
value=${note.folder_id || ''}
onChange=${async function(e) {
await api.post('/notes/move', { note_id: note.id, folder_id: e.target.value });
onRefresh();
}}>
<option value="">Unfiled</option>
${folderOptions.map(function(f) {
return html`<option key=${f.id} value=${f.id}>${f.label}</option>`;
})}
</select>
<span class=${dirty ? 'notes-saved notes-saved--dirty' : 'notes-saved'}>
${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')}
</span>
@@ -292,12 +458,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 +474,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 +496,7 @@
} finally {
setLoading(false);
}
}, [searchTerm]);
}, [searchTerm, activeFolderId, showUnfiled]);
// ── Load single note (full body) ──────────
var loadNote = useCallback(async function(id) {
@@ -335,6 +510,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 +527,7 @@
} catch (e) { /* ignore */ }
}, []);
useEffect(function() { loadNotes(); loadStats(); }, [loadNotes]);
useEffect(function() { loadNotes(); loadFolders(); loadStats(); }, [loadNotes]);
// select note
function handleSelect(id) {
@@ -354,7 +538,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 +567,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 +624,13 @@
<span class="notes-sidebar__title">Notes</span>
${stats && html`<span class="notes-sidebar__count">${stats.total}</span>`}
</div>
<${FolderTree} folders=${folders}
activeFolderId=${activeFolderId} showUnfiled=${showUnfiled}
onSelectFolder=${handleSelectFolder} onSelectAll=${handleSelectAll}
onSelectUnfiled=${handleSelectUnfiled}
onCreateFolder=${handleCreateFolder}
onRenameFolder=${handleRenameFolder}
onDeleteFolder=${handleDeleteFolder} />
<div class="notes-search">
<input type="text" placeholder="Search notes…"
value=${searchTerm} onInput=${handleSearch} />
@@ -418,7 +651,7 @@
})}
</div>
</div>
<${EditorPane} note=${activeNote}
<${EditorPane} note=${activeNote} folders=${folders}
onSave=${handleRefresh}
onDelete=${handleDelete}
onRefresh=${handleRefresh} />

View File

@@ -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"]
]
}
},

View File

@@ -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})