This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/packages/notes/script.star
Jeffrey Smith 2c8dc59284
All checks were successful
CI/CD / detect-changes (push) Successful in 19s
CI/CD / test-frontend (push) Successful in 22s
CI/CD / test-go-pg (push) Successful in 2m26s
CI/CD / test-sqlite (push) Successful in 3m5s
CI/CD / build-and-deploy (push) Successful in 1m23s
Feat v0.4.0 notes surface (#22)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-29 11:16:38 +00:00

252 lines
8.0 KiB
Plaintext

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