Feat v0.4.3 backlinks wikilinks (#25)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 4s
CI/CD / test-go-pg (push) Successful in 2m41s
CI/CD / test-sqlite (push) Successful in 2m43s
CI/CD / build-and-deploy (push) Successful in 25s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #25.
This commit is contained in:
2026-03-29 17:05:44 +00:00
committed by xcaliber
parent 32beb3cee4
commit 31ab572c95
7 changed files with 391 additions and 16 deletions

View File

@@ -1,4 +1,4 @@
# Notes — Starlark Backend (v0.3.0)
# Notes — Starlark Backend (v0.4.0)
#
# Markdown notes surface using ext_data.
#
@@ -55,6 +55,56 @@ def _tags_for_notes(note_ids):
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/*)
# ═══════════════════════════════════════════════
@@ -83,6 +133,15 @@ def on_request(req):
if method == "POST" and path == "/notes/move":
return _move_note(req)
# ── 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":
@@ -195,15 +254,17 @@ def _create_note(req):
if not title:
title = "Untitled"
body_text = _str(body.get("body", ""))
row = db.insert("notes", {
"title": title,
"body": _str(body.get("body", "")),
"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)
@@ -247,6 +308,10 @@ def _update_note(note_id, req):
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 {})
@@ -260,6 +325,14 @@ def _delete_note(note_id, req):
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"})
@@ -350,6 +423,46 @@ def _set_note_tags(note_id, req):
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})
# ═══════════════════════════════════════════════
# Folder CRUD
# ═══════════════════════════════════════════════
@@ -506,4 +619,8 @@ def _get_stats():
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})
# 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})