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) <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -258,6 +379,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>
|
||||
${(folders || []).map(function(f) {
|
||||
return html`<option key=${f.id} value=${f.id}>${f.name}</option>`;
|
||||
})}
|
||||
</select>
|
||||
<span class=${dirty ? 'notes-saved notes-saved--dirty' : 'notes-saved'}>
|
||||
${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')}
|
||||
</span>
|
||||
@@ -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 @@
|
||||
<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 +617,7 @@
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<${EditorPane} note=${activeNote}
|
||||
<${EditorPane} note=${activeNote} folders=${folders}
|
||||
onSave=${handleRefresh}
|
||||
onDelete=${handleDelete}
|
||||
onRefresh=${handleRefresh} />
|
||||
|
||||
@@ -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"]
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user