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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user