Feat v0.4.0 notes surface (#22)
Add Notes as a standard installable surface package (type: full, tier: starlark). Markdown editor with sidebar note list, live preview toggle, auto-save, pin/archive, and full-text search. Zero kernel changes — proves extension stack E2E. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
251
packages/notes/script.star
Normal file
251
packages/notes/script.star
Normal file
@@ -0,0 +1,251 @@
|
||||
# Notes — Starlark Backend (v0.1.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)
|
||||
|
||||
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})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 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
|
||||
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
|
||||
return _resp(200, {"total": total, "pinned": pinned, "archived": archived})
|
||||
Reference in New Issue
Block a user