Feat v0.4.2 tags search (#24)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #24.
This commit is contained in:
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user