From 1e58433936da1dfb683fbad87541d79ba7bee42d Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 21 May 2026 13:34:56 -0400 Subject: [PATCH] feat(db): add wiki-link extraction, resolution, and backlinks [[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. --- internal/db/db.go | 16 ++- internal/db/entities.go | 14 +++ internal/db/links.go | 83 +++++++++++++ internal/db/links_test.go | 225 ++++++++++++++++++++++++++++++++++ internal/link/extract.go | 27 ++++ internal/link/extract_test.go | 38 ++++++ internal/tui/commands.go | 14 +++ internal/tui/detail.go | 35 ++++-- internal/tui/model.go | 8 ++ internal/tui/styles.go | 2 + 10 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 internal/db/links.go create mode 100644 internal/db/links_test.go create mode 100644 internal/link/extract.go create mode 100644 internal/link/extract_test.go diff --git a/internal/db/db.go b/internal/db/db.go index efb7040..4ead6bd 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 { diff --git a/internal/db/entities.go b/internal/db/entities.go index b27509e..1fb0888 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -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 = ?`, diff --git a/internal/db/links.go b/internal/db/links.go new file mode 100644 index 0000000..a3722eb --- /dev/null +++ b/internal/db/links.go @@ -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() +} diff --git a/internal/db/links_test.go b/internal/db/links_test.go new file mode 100644 index 0000000..2877569 --- /dev/null +++ b/internal/db/links_test.go @@ -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)) + } +} diff --git a/internal/link/extract.go b/internal/link/extract.go new file mode 100644 index 0000000..2f5aef0 --- /dev/null +++ b/internal/link/extract.go @@ -0,0 +1,27 @@ +package link + +import ( + "regexp" + "strings" +) + +var linkRe = regexp.MustCompile(`\[\[(.+?)\]\]`) + +func ExtractLinks(body string) []string { + matches := linkRe.FindAllStringSubmatch(body, -1) + if len(matches) == 0 { + return nil + } + + seen := map[string]bool{} + var result []string + for _, m := range matches { + text := strings.TrimSpace(m[1]) + if text == "" || seen[text] { + continue + } + seen[text] = true + result = append(result, text) + } + return result +} diff --git a/internal/link/extract_test.go b/internal/link/extract_test.go new file mode 100644 index 0000000..947eec4 --- /dev/null +++ b/internal/link/extract_test.go @@ -0,0 +1,38 @@ +package link + +import "testing" + +func TestExtractLinks(t *testing.T) { + tests := []struct { + name string + body string + want []string + }{ + {"no links", "plain text with no links", nil}, + {"single link", "see [[nginx config]] for details", []string{"nginx config"}}, + {"multiple links", "see [[nginx config]] and [[deploy steps]]", []string{"nginx config", "deploy steps"}}, + {"duplicate deduped", "[[foo]] then [[foo]] again", []string{"foo"}}, + {"empty brackets", "empty [[ ]] ignored", nil}, + {"just brackets no content", "[[]] empty", nil}, + {"link with special chars", "see [[deploy: staging (v2)]]", []string{"deploy: staging (v2)"}}, + {"link in markdown", "# heading\n\nsee [[my note]] for info", []string{"my note"}}, + {"adjacent links", "[[one]][[two]]", []string{"one", "two"}}, + {"partial brackets ignored", "not a [link] or [[incomplete", nil}, + {"link with hash", "see [[#ops channel]]", []string{"#ops channel"}}, + {"multiline body", "line one [[link one]]\nline two [[link two]]", []string{"link one", "link two"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractLinks(tt.body) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("got %v, want %v", got, tt.want) + } + } + }) + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index f6b64c1..6f591c8 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -55,6 +55,10 @@ type stepsPersistedMsg struct{} type templateCopiedMsg struct{} +type backlinksLoadedMsg struct { + backlinks []db.Backlink +} + type tagsLoadedMsg struct { tags []db.TagCount } @@ -194,6 +198,16 @@ func loadTags(store *db.Store) tea.Cmd { } } +func loadBacklinks(store *db.Store, entityID string) tea.Cmd { + return func() tea.Msg { + backlinks, err := store.LoadBacklinks(context.Background(), entityID) + if err != nil { + return errMsg{err} + } + return backlinksLoadedMsg{backlinks} + } +} + func loadRailTags(store *db.Store) tea.Cmd { return func() tea.Msg { tags, err := store.ListTags(context.Background(), false) diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 80a2d55..dbd53a8 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -21,13 +21,14 @@ const ( ) type detailModel struct { - entity *db.Entity - scroll int - height int - width int - mode detailMode - run runModel - fill fillModel + entity *db.Entity + backlinks []db.Backlink + scroll int + height int + width int + mode detailMode + run runModel + fill fillModel } func newDetailModel() detailModel { @@ -36,6 +37,7 @@ func newDetailModel() detailModel { func (d *detailModel) setEntity(e *db.Entity) { d.entity = e + d.backlinks = nil d.scroll = 0 d.mode = detailPreview } @@ -144,6 +146,25 @@ func (d detailModel) previewView(width int) string { b.WriteString("\n") } + if len(d.backlinks) > 0 { + b.WriteString("\n") + b.WriteString(detailLabelStyle.Render(" ← backlinks")) + b.WriteString("\n") + for _, bl := range d.backlinks { + label := bl.Body + if bl.Title != nil { + label = *bl.Title + } else if len(label) > 40 { + label = label[:40] + "…" + } + line := " " + backlinkStyle.Render(label) + if bl.LinkText != "" { + line += " " + hintDescStyle.Render("(as \""+bl.LinkText+"\")") + } + b.WriteString(line + "\n") + } + } + b.WriteString("\n") meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime)) if e.ModifiedAt != e.CreatedAt { diff --git a/internal/tui/model.go b/internal/tui/model.go index c128980..530bcf7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -287,6 +287,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.detail.mode = detailPreview return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("copied resolved")) + case backlinksLoadedMsg: + if m.detail.entity != nil { + m.detail.backlinks = msg.backlinks + } + return m, nil + case tagsLoadedMsg: m.filter.setTags(msg.tags) m.tagRail.setTags(msg.tags) @@ -865,6 +871,7 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } else { m.state = stateDetail } + return m, loadBacklinks(m.store, e.ID) } } return m, nil @@ -1212,6 +1219,7 @@ func (m model) selectedEntity() *db.Entity { func (m model) reloadDetail(id string) tea.Cmd { return tea.Batch( loadEntities(m.store, m.listParams()), + loadBacklinks(m.store, id), func() tea.Msg { e, err := m.store.Get(context.Background(), id) if err != nil { diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 41d594a..9c87895 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -43,6 +43,7 @@ var ( stumbleAgeStyle lipgloss.Style acSelectedStyle lipgloss.Style acItemStyle lipgloss.Style + backlinkStyle lipgloss.Style ) func init() { @@ -100,4 +101,5 @@ func applyTheme() { stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind) acSelectedStyle = lipgloss.NewStyle().Foreground(accent).Bold(true) acItemStyle = lipgloss.NewStyle().Foreground(muted) + backlinkStyle = lipgloss.NewStyle().Foreground(muted) }