Feat v0.4.2 tags search
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 3s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m37s
CI/CD / test-sqlite (pull_request) Successful in 2m40s
CI/CD / build-and-deploy (pull_request) Successful in 1m53s

Tags table (ext_notes_tags), tag CRUD API, tag filtering in sidebar,
tag pills on note cards, drag-and-drop note-to-folder moves, folder
context menu replacing prompt() hack. Notes package bumped to v0.3.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 13:42:03 +00:00
parent 03c182b9d1
commit f8b42187e7
7 changed files with 661 additions and 48 deletions

View File

@@ -1,4 +1,4 @@
# Notes — Starlark Backend (v0.2.0)
# Notes — Starlark Backend (v0.3.0)
#
# Markdown notes surface using ext_data.
#
@@ -39,6 +39,21 @@ def _snippet(body, max_len = 120):
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
# ═══════════════════════════════════════════════
# Surface API routes (/s/notes/api/*)
@@ -64,6 +79,24 @@ def on_request(req):
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)
# ── 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/"):])
@@ -76,11 +109,7 @@ def on_request(req):
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 ──────────────────────────
# ── Folder routes ─────────────────────────
# GET /folders
if method == "GET" and path == "/folders":
return _list_folders()
@@ -101,7 +130,7 @@ def on_request(req):
# ═══════════════════════════════════════════════
# CRUD
# Notes CRUD
# ═══════════════════════════════════════════════
def _list_notes(req):
@@ -132,11 +161,19 @@ def _list_notes(req):
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": r.get("id", ""),
"id": nid,
"title": r.get("title", ""),
"snippet": _snippet(r.get("body", "")),
"folder_id": r.get("folder_id", ""),
@@ -145,6 +182,7 @@ def _list_notes(req):
"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})
@@ -173,7 +211,16 @@ 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])
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):
@@ -209,6 +256,10 @@ def _delete_note(note_id, req):
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"])
ok = db.delete("notes", note_id)
if not ok:
return _resp(404, {"error": "note not found"})
@@ -241,6 +292,64 @@ def _move_note(req):
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})
# ═══════════════════════════════════════════════
# Folder CRUD
# ═══════════════════════════════════════════════
@@ -331,19 +440,36 @@ def _search_notes(req):
# (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()
if term_lower in title or term_lower in body:
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": r.get("id", ""),
"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})
@@ -370,4 +496,14 @@ def _get_stats():
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})
# 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)
return _resp(200, {"total": total, "pinned": pinned, "archived": archived, "unfiled": unfiled, "folders": folder_count, "tags": tag_count})