# Notes — Starlark Backend (v0.2.0) # # Markdown notes surface using ext_data. # # Entry points: # on_request(req) → surface API routes # # Modules: db, json, settings # ═══════════════════════════════════════════════ # Helpers # ═══════════════════════════════════════════════ def _resp(status, data): return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}} def _str(v): if v == None: return "" return str(v) def _int(v): if v == None: return 0 s = str(v) if not s: return 0 return int(s) def _snippet(body, max_len = 120): """First max_len chars of body, stripped of leading #/whitespace.""" if not body: return "" line = body.split("\n")[0] # strip leading markdown heading markers line = line.lstrip("#").strip() if len(line) > max_len: return line[:max_len] return line # ═══════════════════════════════════════════════ # Surface API routes (/s/notes/api/*) # ═══════════════════════════════════════════════ def on_request(req): path = req["path"] method = req["method"] # GET /notes — list if method == "GET" and path == "/notes": return _list_notes(req) # POST /notes — create if method == "POST" and path == "/notes": return _create_note(req) # GET /stats if method == "GET" and path == "/stats": return _get_stats() # GET /search if method == "GET" and path == "/search": return _search_notes(req) # GET /notes/:id if method == "GET" and path.startswith("/notes/"): return _get_note(path[len("/notes/"):]) # PUT /notes/:id if method == "PUT" and path.startswith("/notes/"): return _update_note(path[len("/notes/"):], req) # DELETE /notes/:id 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"}) # ═══════════════════════════════════════════════ # CRUD # ═══════════════════════════════════════════════ def _list_notes(req): q = req.get("query", {}) filters = {} folder = _str(q.get("folder_id", "")) if folder: filters["folder_id"] = folder pinned = _str(q.get("pinned", "")) if pinned: filters["pinned"] = int(pinned) archived = _str(q.get("archived", "")) if archived: filters["archived"] = int(archived) else: # default: exclude archived filters["archived"] = 0 creator = _str(q.get("creator_id", "")) if creator: filters["creator_id"] = creator order = _str(q.get("order", "")) or "updated_at" limit_str = _str(q.get("limit", "")) limit = int(limit_str) if limit_str else 200 rows = db.query("notes", filters=filters, order=order, limit=limit) # lightweight projection: strip body for list view items = [] for r in (rows or []): items.append({ "id": r.get("id", ""), "title": r.get("title", ""), "snippet": _snippet(r.get("body", "")), "folder_id": r.get("folder_id", ""), "creator_id": r.get("creator_id", ""), "updated_at": r.get("updated_at", ""), "pinned": _int(r.get("pinned", 0)), "archived": _int(r.get("archived", 0)), "created_at": r.get("created_at", ""), }) return _resp(200, {"data": items}) def _create_note(req): body = json.decode(req.get("body", "{}")) user_id = req.get("user_id", "") title = _str(body.get("title", "")) if not title: title = "Untitled" row = db.insert("notes", { "title": title, "body": _str(body.get("body", "")), "folder_id": _str(body.get("folder_id", "")), "creator_id": user_id, "updated_at": "", "pinned": _int(body.get("pinned", 0)), "archived": 0, }) return _resp(201, row) def _get_note(note_id): rows = db.query("notes", filters={"id": note_id}, limit=1) if not rows: return _resp(404, {"error": "note not found"}) return _resp(200, rows[0]) def _update_note(note_id, req): body = json.decode(req.get("body", "{}")) existing = db.query("notes", filters={"id": note_id}, limit=1) if not existing: return _resp(404, {"error": "note not found"}) updates = {} for key in ["title", "body", "folder_id"]: if key in body: updates[key] = _str(body[key]) if "pinned" in body: updates["pinned"] = _int(body["pinned"]) if "archived" in body: updates["archived"] = _int(body["archived"]) # touch updated_at updates["updated_at"] = "now" ok = db.update("notes", note_id, updates) if not ok: return _resp(500, {"error": "update failed"}) rows = db.query("notes", filters={"id": note_id}, limit=1) return _resp(200, rows[0] if rows else {}) def _delete_note(note_id, req): q = req.get("query", {}) hard = _str(q.get("hard", "")) if hard == "1": ok = db.delete("notes", note_id) if not ok: return _resp(404, {"error": "note not found"}) return _resp(200, {"deleted": True}) # soft delete: archive ok = db.update("notes", note_id, {"archived": 1, "updated_at": "now"}) if not ok: return _resp(404, {"error": "note not found"}) 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 # ═══════════════════════════════════════════════ def _search_notes(req): q = req.get("query", {}) term = _str(q.get("q", "")) if not term: return _resp(400, {"error": "q parameter required"}) # fetch all non-archived notes and filter in Starlark # (ext_data db.query doesn't support LIKE — filter client-side) rows = db.query("notes", filters={"archived": 0}, order="updated_at", limit=1000) term_lower = term.lower() matches = [] for r in (rows or []): title = _str(r.get("title", "")).lower() body = _str(r.get("body", "")).lower() if term_lower in title or term_lower in body: matches.append({ "id": r.get("id", ""), "title": r.get("title", ""), "snippet": _snippet(r.get("body", "")), "folder_id": r.get("folder_id", ""), "updated_at": r.get("updated_at", ""), "pinned": _int(r.get("pinned", 0)), "created_at": r.get("created_at", ""), }) return _resp(200, {"data": matches}) # ═══════════════════════════════════════════════ # Stats # ═══════════════════════════════════════════════ def _get_stats(): all_notes = db.query("notes", limit=10000) notes = all_notes or [] total = 0 pinned = 0 archived = 0 unfiled = 0 for n in notes: if _int(n.get("archived", 0)) == 1: archived = archived + 1 else: total = total + 1 if _int(n.get("pinned", 0)) == 1: pinned = pinned + 1 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})