feat(db): add wiki-link extraction, resolution, and backlinks
CI / test (pull_request) Successful in 2m27s

[[wiki-links]] in entry bodies are extracted at save time, resolved
to entity IDs (title match first, body substring fallback), and
stored in entity_links junction table. Backlinks surface in TUI
detail view showing entries that link to the current entry.

Schema migration v5 adds entity_links with CASCADE/SET NULL
semantics. Links sync on Create, Update, and Absorb.
This commit is contained in:
2026-05-21 13:34:56 -04:00
parent d24df8432f
commit 1e58433936
10 changed files with 454 additions and 8 deletions
+15 -1
View File
@@ -56,7 +56,7 @@ func (s *Store) Backup(dst string) error {
return err
}
const currentSchema = 4
const currentSchema = 5
var migrations = []func(db *sql.DB) error{
// v1: initial schema
@@ -188,6 +188,20 @@ var migrations = []func(db *sql.DB) error{
}
return nil
},
// v5: add entity_links table for wiki-links
func(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE entity_links (
from_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
to_id TEXT REFERENCES entities(id) ON DELETE SET NULL,
link_text TEXT NOT NULL,
PRIMARY KEY (from_id, link_text)
);
CREATE INDEX idx_entity_links_to ON entity_links(to_id) WHERE to_id IS NOT NULL;
`)
return err
},
}
func (s *Store) migrate() error {
+14
View File
@@ -152,6 +152,10 @@ func (s *Store) Create(ctx context.Context, e *Entity) error {
return err
}
if err := syncLinks(ctx, tx, s, e.ID, e.Body); err != nil {
return err
}
return tx.Commit()
}
@@ -383,6 +387,12 @@ func (s *Store) Update(ctx context.Context, id string, u *EntityUpdate) error {
}
}
if u.Body != nil {
if err := syncLinks(ctx, tx, s, existing.ID, *u.Body); err != nil {
return err
}
}
return tx.Commit()
}
@@ -495,6 +505,10 @@ func (s *Store) Absorb(ctx context.Context, targetID, sourceID string) error {
}
}
if err := syncLinks(ctx, tx, s, targetID, merged); err != nil {
return err
}
if source.CardType != nil {
if _, err := tx.ExecContext(ctx, `UPDATE entities SET card_type = NULL, card_data = NULL,
use_count = 0, last_used_at = NULL, modified_at = ? WHERE id = ?`,
+83
View File
@@ -0,0 +1,83 @@
package db
import (
"context"
"database/sql"
"strings"
"github.com/lerko/nib/internal/link"
)
type Backlink struct {
EntityID string
Title *string
Body string
LinkText string
}
func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, excludeID string) *string {
lower := strings.ToLower(linkText)
var id string
err := tx.QueryRowContext(ctx, `
SELECT id FROM entities
WHERE LOWER(title) = ? AND id != ? AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`, lower, excludeID).Scan(&id)
if err == nil {
return &id
}
err = tx.QueryRowContext(ctx, `
SELECT id FROM entities
WHERE LOWER(body) LIKE ? AND id != ? AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`, "%"+lower+"%", excludeID).Scan(&id)
if err == nil {
return &id
}
return nil
}
func syncLinks(ctx context.Context, tx *sql.Tx, s *Store, entityID string, body string) error {
if _, err := tx.ExecContext(ctx, "DELETE FROM entity_links WHERE from_id = ?", entityID); err != nil {
return err
}
linkTexts := link.ExtractLinks(body)
for _, lt := range linkTexts {
toID := s.resolveLink(ctx, tx, lt, entityID)
if _, err := tx.ExecContext(ctx,
"INSERT OR IGNORE INTO entity_links (from_id, to_id, link_text) VALUES (?, ?, ?)",
entityID, toID, lt); err != nil {
return err
}
}
return nil
}
func (s *Store) LoadBacklinks(ctx context.Context, entityID string) ([]Backlink, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT e.id, e.title, e.body, el.link_text
FROM entity_links el
JOIN entities e ON e.id = el.from_id
WHERE el.to_id = ? AND e.deleted_at IS NULL
ORDER BY e.created_at DESC`, entityID)
if err != nil {
return nil, err
}
defer rows.Close()
var backlinks []Backlink
for rows.Next() {
var bl Backlink
var title sql.NullString
if err := rows.Scan(&bl.EntityID, &title, &bl.Body, &bl.LinkText); err != nil {
return nil, err
}
if title.Valid {
bl.Title = &title.String
}
backlinks = append(backlinks, bl)
}
return backlinks, rows.Err()
}
+225
View File
@@ -0,0 +1,225 @@
package db
import (
"context"
"testing"
)
func TestSyncLinks_OnCreate(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "nginx proxy config", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "see [[nginx proxy config]] for setup", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("expected 1 backlink, got %d", len(backlinks))
}
if backlinks[0].EntityID != source.ID {
t.Errorf("backlink entity = %s, want %s", backlinks[0].EntityID, source.ID)
}
if backlinks[0].LinkText != "nginx proxy config" {
t.Errorf("link text = %q, want %q", backlinks[0].LinkText, "nginx proxy config")
}
}
func TestSyncLinks_TitleMatch(t *testing.T) {
s := testStore(t)
ctx := context.Background()
title := "deploy checklist"
target := &Entity{Body: "steps to deploy", Title: &title, Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "follow [[deploy checklist]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("expected 1 backlink, got %d", len(backlinks))
}
}
func TestSyncLinks_TitlePriority(t *testing.T) {
s := testStore(t)
ctx := context.Background()
title := "nginx config"
titled := &Entity{Body: "some body", Title: &title, Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, titled); err != nil {
t.Fatal(err)
}
bodyMatch := &Entity{Body: "nginx config details", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, bodyMatch); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "see [[nginx config]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, titled.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("title match should win, got %d backlinks on titled entity", len(backlinks))
}
bodyBacklinks, err := s.LoadBacklinks(ctx, bodyMatch.ID)
if err != nil {
t.Fatal(err)
}
if len(bodyBacklinks) != 0 {
t.Fatalf("body match entity should have 0 backlinks, got %d", len(bodyBacklinks))
}
}
func TestSyncLinks_Unresolved(t *testing.T) {
s := testStore(t)
ctx := context.Background()
source := &Entity{Body: "see [[nonexistent entry]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM entity_links WHERE from_id = ?", source.ID).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("expected 1 link row (unresolved), got %d", count)
}
var toID *string
err = s.db.QueryRow("SELECT to_id FROM entity_links WHERE from_id = ?", source.ID).Scan(&toID)
if err != nil {
t.Fatal(err)
}
if toID != nil {
t.Errorf("expected NULL to_id for unresolved link, got %v", *toID)
}
}
func TestSyncLinks_OnUpdate(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "original target", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "no links yet", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
newBody := "now has [[original target]]"
if err := s.Update(ctx, source.ID, &EntityUpdate{Body: &newBody}); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("expected 1 backlink after update, got %d", len(backlinks))
}
}
func TestSyncLinks_SelfLinkUnresolved(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "I reference [[I reference]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
var toID *string
err := s.db.QueryRow("SELECT to_id FROM entity_links WHERE from_id = ?", e.ID).Scan(&toID)
if err != nil {
t.Fatal(err)
}
if toID != nil {
t.Fatalf("self-matching link should be unresolved (NULL to_id), got %v", *toID)
}
}
func TestSyncLinks_NoLinks(t *testing.T) {
s := testStore(t)
ctx := context.Background()
e := &Entity{Body: "plain text no links", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, e); err != nil {
t.Fatal(err)
}
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM entity_links WHERE from_id = ?", e.ID).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("expected 0 links, got %d", count)
}
}
func TestSyncLinks_DeletedSourceHidden(t *testing.T) {
s := testStore(t)
ctx := context.Background()
target := &Entity{Body: "target entry", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, target); err != nil {
t.Fatal(err)
}
source := &Entity{Body: "see [[target entry]]", Glyph: "note", Tags: []string{}}
if err := s.Create(ctx, source); err != nil {
t.Fatal(err)
}
backlinks, err := s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Fatalf("expected 1 backlink before delete, got %d", len(backlinks))
}
if _, err := s.SoftDelete(ctx, source.ID); err != nil {
t.Fatal(err)
}
backlinks, err = s.LoadBacklinks(ctx, target.ID)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 0 {
t.Fatalf("soft-deleted source should not appear in backlinks, got %d", len(backlinks))
}
}