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