All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
252 lines
8.0 KiB
Plaintext
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})
|