feat(db): add wiki-link extraction, resolution, and backlinks
CI / test (pull_request) Successful in 2m27s
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:
+15
-1
@@ -56,7 +56,7 @@ func (s *Store) Backup(dst string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSchema = 4
|
const currentSchema = 5
|
||||||
|
|
||||||
var migrations = []func(db *sql.DB) error{
|
var migrations = []func(db *sql.DB) error{
|
||||||
// v1: initial schema
|
// v1: initial schema
|
||||||
@@ -188,6 +188,20 @@ var migrations = []func(db *sql.DB) error{
|
|||||||
}
|
}
|
||||||
return nil
|
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 {
|
func (s *Store) migrate() error {
|
||||||
|
|||||||
@@ -152,6 +152,10 @@ func (s *Store) Create(ctx context.Context, e *Entity) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := syncLinks(ctx, tx, s, e.ID, e.Body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
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()
|
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 source.CardType != nil {
|
||||||
if _, err := tx.ExecContext(ctx, `UPDATE entities SET card_type = NULL, card_data = NULL,
|
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 = ?`,
|
use_count = 0, last_used_at = NULL, modified_at = ? WHERE id = ?`,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ type stepsPersistedMsg struct{}
|
|||||||
|
|
||||||
type templateCopiedMsg struct{}
|
type templateCopiedMsg struct{}
|
||||||
|
|
||||||
|
type backlinksLoadedMsg struct {
|
||||||
|
backlinks []db.Backlink
|
||||||
|
}
|
||||||
|
|
||||||
type tagsLoadedMsg struct {
|
type tagsLoadedMsg struct {
|
||||||
tags []db.TagCount
|
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 {
|
func loadRailTags(store *db.Store) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
tags, err := store.ListTags(context.Background(), false)
|
tags, err := store.ListTags(context.Background(), false)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const (
|
|||||||
|
|
||||||
type detailModel struct {
|
type detailModel struct {
|
||||||
entity *db.Entity
|
entity *db.Entity
|
||||||
|
backlinks []db.Backlink
|
||||||
scroll int
|
scroll int
|
||||||
height int
|
height int
|
||||||
width int
|
width int
|
||||||
@@ -36,6 +37,7 @@ func newDetailModel() detailModel {
|
|||||||
|
|
||||||
func (d *detailModel) setEntity(e *db.Entity) {
|
func (d *detailModel) setEntity(e *db.Entity) {
|
||||||
d.entity = e
|
d.entity = e
|
||||||
|
d.backlinks = nil
|
||||||
d.scroll = 0
|
d.scroll = 0
|
||||||
d.mode = detailPreview
|
d.mode = detailPreview
|
||||||
}
|
}
|
||||||
@@ -144,6 +146,25 @@ func (d detailModel) previewView(width int) string {
|
|||||||
b.WriteString("\n")
|
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")
|
b.WriteString("\n")
|
||||||
meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime))
|
meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime))
|
||||||
if e.ModifiedAt != e.CreatedAt {
|
if e.ModifiedAt != e.CreatedAt {
|
||||||
|
|||||||
@@ -287,6 +287,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.detail.mode = detailPreview
|
m.detail.mode = detailPreview
|
||||||
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("copied resolved"))
|
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:
|
case tagsLoadedMsg:
|
||||||
m.filter.setTags(msg.tags)
|
m.filter.setTags(msg.tags)
|
||||||
m.tagRail.setTags(msg.tags)
|
m.tagRail.setTags(msg.tags)
|
||||||
@@ -865,6 +871,7 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
m.state = stateDetail
|
m.state = stateDetail
|
||||||
}
|
}
|
||||||
|
return m, loadBacklinks(m.store, e.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -1212,6 +1219,7 @@ func (m model) selectedEntity() *db.Entity {
|
|||||||
func (m model) reloadDetail(id string) tea.Cmd {
|
func (m model) reloadDetail(id string) tea.Cmd {
|
||||||
return tea.Batch(
|
return tea.Batch(
|
||||||
loadEntities(m.store, m.listParams()),
|
loadEntities(m.store, m.listParams()),
|
||||||
|
loadBacklinks(m.store, id),
|
||||||
func() tea.Msg {
|
func() tea.Msg {
|
||||||
e, err := m.store.Get(context.Background(), id)
|
e, err := m.store.Get(context.Background(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ var (
|
|||||||
stumbleAgeStyle lipgloss.Style
|
stumbleAgeStyle lipgloss.Style
|
||||||
acSelectedStyle lipgloss.Style
|
acSelectedStyle lipgloss.Style
|
||||||
acItemStyle lipgloss.Style
|
acItemStyle lipgloss.Style
|
||||||
|
backlinkStyle lipgloss.Style
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -100,4 +101,5 @@ func applyTheme() {
|
|||||||
stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind)
|
stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind)
|
||||||
acSelectedStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
acSelectedStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
|
||||||
acItemStyle = lipgloss.NewStyle().Foreground(muted)
|
acItemStyle = lipgloss.NewStyle().Foreground(muted)
|
||||||
|
backlinkStyle = lipgloss.NewStyle().Foreground(muted)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user