# Notes — Starlark Backend (v0.4.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 def _tags_for_notes(note_ids): """Batch-fetch tags for a list of note IDs. Returns {note_id: [tag, ...]}.""" if not note_ids: return {} all_tags = db.query("tags", limit=5000) result = {} for t in (all_tags or []): nid = _str(t.get("note_id", "")) tag = _str(t.get("tag", "")) if nid and tag: if nid not in result: result[nid] = [] result[nid].append(tag) return result def _extract_wikilinks(text): """Parse [[...]] patterns from text. Returns deduplicated list of link_text strings.""" links = [] seen = {} parts = text.split("[[") # parts[0] is before any wikilink; parts[1:] each start after "[[" for i in range(1, len(parts)): chunk = parts[i] end = chunk.find("]]") if end > 0: link_text = chunk[:end].strip() lt_lower = link_text.lower() if link_text and lt_lower not in seen: seen[lt_lower] = True links.append(link_text) return links def _sync_links(source_id, body): """Extract wikilinks from body, resolve to note IDs, replace link rows.""" raw_links = _extract_wikilinks(_str(body)) if not raw_links and not db.query("links", filters={"source_id": source_id}, limit=1): return # nothing to do # resolve titles to note IDs (case-insensitive) all_notes = db.query("notes", filters={"archived": 0}, limit=5000) title_map = {} for n in (all_notes or []): t = _str(n.get("title", "")).lower().strip() if t: title_map[t] = n.get("id", "") # delete existing links for this source old_links = db.query("links", filters={"source_id": source_id}, limit=500) for link in (old_links or []): db.delete("links", link["id"]) # insert new links for lt in raw_links: target_id = title_map.get(lt.lower().strip(), "") # skip self-links if target_id == source_id: target_id = "" db.insert("links", { "source_id": source_id, "target_id": target_id, "link_text": lt, }) # ═══════════════════════════════════════════════ # 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) # POST /notes/move (must be before /notes/:id catch-all) if method == "POST" and path == "/notes/move": return _move_note(req) # GET /graph — full graph data for visualization if method == "GET" and path == "/graph": return _get_graph() # ── Link routes ────────────────────────── # GET /links/:note_id — outgoing links if method == "GET" and path.startswith("/links/"): return _get_links(path[len("/links/"):]) # GET /backlinks/:note_id — incoming links if method == "GET" and path.startswith("/backlinks/"): return _get_backlinks(path[len("/backlinks/"):]) # ── Tag routes ──────────────────────────── # GET /tags — all unique tags if method == "GET" and path == "/tags": return _list_tags() # GET /tags/:note_id if method == "GET" and path.startswith("/tags/"): return _get_note_tags(path[len("/tags/"):]) # PUT /tags/:note_id if method == "PUT" and path.startswith("/tags/"): return _set_note_tags(path[len("/tags/"):], req) # ── Note routes ─────────────────────────── # 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) # ── 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"}) # ═══════════════════════════════════════════════ # Notes 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) # batch-fetch tags for all notes note_ids = [] for r in (rows or []): note_ids.append(r.get("id", "")) tags_map = _tags_for_notes(note_ids) # lightweight projection: strip body for list view items = [] for r in (rows or []): nid = r.get("id", "") items.append({ "id": nid, "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", ""), "tags": tags_map.get(nid, []), }) 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" body_text = _str(body.get("body", "")) row = db.insert("notes", { "title": title, "body": body_text, "folder_id": _str(body.get("folder_id", "")), "creator_id": user_id, "updated_at": "", "pinned": _int(body.get("pinned", 0)), "archived": 0, }) _sync_links(row["id"], body_text) 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"}) note = rows[0] # include tags tag_rows = db.query("tags", filters={"note_id": note_id}, limit=100) tags = [] for t in (tag_rows or []): tag = _str(t.get("tag", "")) if tag: tags.append(tag) note["tags"] = tags return _resp(200, note) 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"}) # sync wikilinks when body changes if "body" in body: _sync_links(note_id, _str(body["body"])) 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": # cascade-delete tags for this note note_tags = db.query("tags", filters={"note_id": note_id}, limit=100) for t in (note_tags or []): db.delete("tags", t["id"]) # cascade-delete outgoing links FROM this note out_links = db.query("links", filters={"source_id": note_id}, limit=500) for link in (out_links or []): db.delete("links", link["id"]) # cascade-delete incoming backlinks TO this note in_links = db.query("links", filters={"target_id": note_id}, limit=500) for link in (in_links or []): db.delete("links", link["id"]) 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 {}) # ═══════════════════════════════════════════════ # Tags CRUD # ═══════════════════════════════════════════════ def _list_tags(): """Return sorted list of all unique tag strings.""" all_tags = db.query("tags", limit=5000) seen = {} for t in (all_tags or []): tag = _str(t.get("tag", "")).strip() if tag: seen[tag] = True tags = sorted(seen.keys()) return _resp(200, {"data": tags}) def _get_note_tags(note_id): """Return tags for a specific note.""" rows = db.query("tags", filters={"note_id": note_id}, limit=100) tags = [] for t in (rows or []): tag = _str(t.get("tag", "")) if tag: tags.append(tag) return _resp(200, {"data": tags}) def _set_note_tags(note_id, req): """Replace all tags for a note (delete + reinsert).""" body = json.decode(req.get("body", "{}")) new_tags_raw = body.get("tags", []) # validate note exists existing = db.query("notes", filters={"id": note_id}, limit=1) if not existing: return _resp(404, {"error": "note not found"}) # normalize: lowercase, strip, deduplicate, skip empty seen = {} clean_tags = [] for raw in new_tags_raw: tag = _str(raw).strip().lower() if tag and tag not in seen: seen[tag] = True clean_tags.append(tag) # delete all existing tags for this note old_tags = db.query("tags", filters={"note_id": note_id}, limit=100) for t in (old_tags or []): db.delete("tags", t["id"]) # insert new tags for tag in clean_tags: db.insert("tags", {"note_id": note_id, "tag": tag}) return _resp(200, {"data": clean_tags}) # ═══════════════════════════════════════════════ # Links (backlinks + wikilinks) # ═══════════════════════════════════════════════ def _get_links(note_id): """Return outgoing links for a note.""" rows = db.query("links", filters={"source_id": note_id}, limit=500) items = [] for r in (rows or []): items.append({ "source_id": r.get("source_id", ""), "target_id": r.get("target_id", ""), "link_text": r.get("link_text", ""), }) return _resp(200, {"data": items}) def _get_backlinks(note_id): """Return notes that link TO this note (backlinks), enriched with source titles.""" rows = db.query("links", filters={"target_id": note_id}, limit=500) if not rows: return _resp(200, {"data": []}) # batch-fetch source note titles all_notes = db.query("notes", filters={"archived": 0}, limit=5000) note_map = {} for n in (all_notes or []): note_map[n.get("id", "")] = n.get("title", "Untitled") items = [] for r in rows: sid = r.get("source_id", "") items.append({ "source_id": sid, "source_title": note_map.get(sid, "Unknown"), "link_text": r.get("link_text", ""), }) return _resp(200, {"data": items}) def _get_graph(): """Return all non-archived notes as nodes and resolved links as edges.""" all_notes = db.query("notes", filters={"archived": 0}, limit=5000) notes = all_notes or [] # batch-fetch tags note_ids = [n.get("id", "") for n in notes] tags_map = _tags_for_notes(note_ids) # build nodes + id set for edge filtering nodes = [] note_id_set = {} for n in notes: nid = n.get("id", "") note_id_set[nid] = True nodes.append({ "id": nid, "title": n.get("title", "Untitled"), "folder_id": _str(n.get("folder_id", "")), "tags": tags_map.get(nid, []), }) # fetch links, keep only resolved (both endpoints exist) all_links = db.query("links", limit=10000) edges = [] for link in (all_links or []): src = _str(link.get("source_id", "")) tgt = _str(link.get("target_id", "")) if src and tgt and src in note_id_set and tgt in note_id_set: edges.append({ "source": src, "target": tgt, "text": _str(link.get("link_text", "")), }) return _resp(200, {"nodes": nodes, "edges": edges}) # ═══════════════════════════════════════════════ # 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() # batch-fetch tags for tag-based matching tags_map = _tags_for_notes([r.get("id", "") for r in (rows or [])]) matches = [] for r in (rows or []): title = _str(r.get("title", "")).lower() body = _str(r.get("body", "")).lower() nid = r.get("id", "") # check title and body found = term_lower in title or term_lower in body # check tags if not found: note_tags = tags_map.get(nid, []) for tg in note_tags: if term_lower in tg.lower(): found = True if found: matches.append({ "id": nid, "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", ""), "tags": tags_map.get(nid, []), }) 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 []) # count unique tags all_tags = db.query("tags", limit=10000) tag_seen = {} for t in (all_tags or []): tag = _str(t.get("tag", "")) if tag: tag_seen[tag] = True tag_count = len(tag_seen) # count links all_links = db.query("links", limit=10000) link_count = len(all_links or []) return _resp(200, {"total": total, "pinned": pinned, "archived": archived, "unfiled": unfiled, "folders": folder_count, "tags": tag_count, "links": link_count})