1e58433936
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.
302 lines
6.7 KiB
Go
302 lines
6.7 KiB
Go
package tui
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/glamour"
|
||
|
||
"github.com/lerko/nib/internal/db"
|
||
"github.com/lerko/nib/internal/display"
|
||
)
|
||
|
||
type detailMode int
|
||
|
||
const (
|
||
detailPreview detailMode = iota
|
||
detailRun
|
||
detailFill
|
||
)
|
||
|
||
type detailModel struct {
|
||
entity *db.Entity
|
||
backlinks []db.Backlink
|
||
scroll int
|
||
height int
|
||
width int
|
||
mode detailMode
|
||
run runModel
|
||
fill fillModel
|
||
}
|
||
|
||
func newDetailModel() detailModel {
|
||
return detailModel{}
|
||
}
|
||
|
||
func (d *detailModel) setEntity(e *db.Entity) {
|
||
d.entity = e
|
||
d.backlinks = nil
|
||
d.scroll = 0
|
||
d.mode = detailPreview
|
||
}
|
||
|
||
func (d *detailModel) setSize(width, height int) {
|
||
d.width = width
|
||
d.height = height
|
||
}
|
||
|
||
func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) {
|
||
switch d.mode {
|
||
case detailRun:
|
||
d.run = d.run.update(msg.String())
|
||
return d, nil
|
||
case detailFill:
|
||
var cmd tea.Cmd
|
||
d.fill, cmd = d.fill.update(msg)
|
||
return d, cmd
|
||
default:
|
||
switch msg.String() {
|
||
case "up", "k":
|
||
if d.scroll > 0 {
|
||
d.scroll--
|
||
}
|
||
case "down", "j":
|
||
d.scroll++
|
||
case "pgdown", "ctrl+d":
|
||
d.scroll += d.height
|
||
case "pgup", "ctrl+u":
|
||
d.scroll -= d.height
|
||
if d.scroll < 0 {
|
||
d.scroll = 0
|
||
}
|
||
case "home", "g":
|
||
d.scroll = 0
|
||
case "end", "G":
|
||
d.scroll = 1<<31 - 1
|
||
}
|
||
return d, nil
|
||
}
|
||
}
|
||
|
||
func (d detailModel) view(width int) string {
|
||
switch d.mode {
|
||
case detailRun:
|
||
return d.run.view(width)
|
||
case detailFill:
|
||
return d.fill.view(width)
|
||
default:
|
||
return d.previewView(width)
|
||
}
|
||
}
|
||
|
||
func (d detailModel) previewView(width int) string {
|
||
if d.entity == nil {
|
||
return ""
|
||
}
|
||
|
||
e := d.entity
|
||
var b strings.Builder
|
||
|
||
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
|
||
header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID))
|
||
if e.CardType != nil {
|
||
header += " " + affordanceStyle.Render(string(*e.CardType))
|
||
}
|
||
b.WriteString(detailHeaderStyle.Render(header))
|
||
b.WriteString("\n\n")
|
||
|
||
if e.Title != nil {
|
||
b.WriteString(detailBodyStyle.Render("title: " + *e.Title))
|
||
b.WriteString("\n")
|
||
}
|
||
|
||
bodyWidth := width - 4
|
||
if bodyWidth < 20 {
|
||
bodyWidth = 20
|
||
}
|
||
r, _ := glamour.NewTermRenderer(
|
||
glamour.WithStylePath(glamourStyle()),
|
||
glamour.WithWordWrap(bodyWidth),
|
||
)
|
||
rendered, err := r.Render(e.Body)
|
||
if err != nil {
|
||
rendered = e.Body
|
||
}
|
||
rendered = strings.TrimRight(rendered, "\n")
|
||
b.WriteString(detailBodyStyle.Render(rendered))
|
||
b.WriteString("\n")
|
||
|
||
if e.CardType != nil {
|
||
cardSection := renderCardData(e)
|
||
if cardSection != "" {
|
||
b.WriteString("\n")
|
||
b.WriteString(cardSection)
|
||
}
|
||
}
|
||
|
||
if len(e.Tags) > 0 {
|
||
tagParts := make([]string, len(e.Tags))
|
||
for i, t := range e.Tags {
|
||
tagParts[i] = tagStyle.Render("#" + t)
|
||
}
|
||
b.WriteString("\n")
|
||
b.WriteString(detailBodyStyle.Render(strings.Join(tagParts, " ")))
|
||
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 {
|
||
meta += fmt.Sprintf("\nmodified %s", e.ModifiedAt.Format(time.DateTime))
|
||
}
|
||
if e.TimeAnchor != nil {
|
||
meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor)
|
||
}
|
||
if e.Pinned {
|
||
meta += "\n" + pinnedStyle.Render("pinned")
|
||
}
|
||
if e.CardType != nil {
|
||
meta += fmt.Sprintf("\ncard %s", *e.CardType)
|
||
}
|
||
if e.UseCount > 0 {
|
||
meta += fmt.Sprintf("\nused %d×", e.UseCount)
|
||
}
|
||
if e.CompletedAt != nil {
|
||
meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime))
|
||
}
|
||
b.WriteString(idStyle.Render(meta))
|
||
|
||
lines := strings.Split(b.String(), "\n")
|
||
totalLines := len(lines)
|
||
|
||
maxScroll := totalLines - d.height
|
||
if maxScroll < 0 {
|
||
maxScroll = 0
|
||
}
|
||
scroll := d.scroll
|
||
if scroll > maxScroll {
|
||
scroll = maxScroll
|
||
}
|
||
|
||
if totalLines > d.height && d.height > 0 && len(lines) > 0 {
|
||
indicator := idStyle.Render(fmt.Sprintf(" %d/%d", scroll+1, totalLines))
|
||
lines[0] = lines[0] + indicator
|
||
}
|
||
|
||
if scroll > 0 && scroll < totalLines {
|
||
lines = lines[scroll:]
|
||
}
|
||
if d.height > 0 && len(lines) > d.height {
|
||
lines = lines[:d.height]
|
||
}
|
||
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func renderCardData(e *db.Entity) string {
|
||
if e.CardData == nil {
|
||
return ""
|
||
}
|
||
|
||
data, err := e.CardDataJSON()
|
||
if err != nil || data == nil {
|
||
return ""
|
||
}
|
||
|
||
var b strings.Builder
|
||
|
||
switch *e.CardType {
|
||
case db.CardChecklist:
|
||
steps, ok := data["steps"].([]interface{})
|
||
if !ok {
|
||
break
|
||
}
|
||
done := 0
|
||
for _, s := range steps {
|
||
step, ok := s.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
text, _ := step["text"].(string)
|
||
isDone, _ := step["done"].(bool)
|
||
if isDone {
|
||
done++
|
||
b.WriteString(" " + checkDoneStyle.Render("[✓] "+text) + "\n")
|
||
} else {
|
||
b.WriteString(" " + checkPendingStyle.Render("[ ] "+text) + "\n")
|
||
}
|
||
}
|
||
progress := fmt.Sprintf(" %d/%d steps", done, len(steps))
|
||
b.WriteString(detailLabelStyle.Render(progress))
|
||
b.WriteString(" " + helpStyle.Render("r:run"))
|
||
|
||
case db.CardTemplate:
|
||
slots, ok := data["slots"].([]interface{})
|
||
if !ok {
|
||
break
|
||
}
|
||
b.WriteString(detailLabelStyle.Render(" slots:") + "\n")
|
||
for _, s := range slots {
|
||
slot, ok := s.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
name, _ := slot["name"].(string)
|
||
def, _ := slot["default"].(string)
|
||
line := " ${" + name + "}"
|
||
if def != "" {
|
||
line += " " + detailValueStyle.Render("default: "+def)
|
||
}
|
||
b.WriteString(line + "\n")
|
||
}
|
||
b.WriteString(" " + helpStyle.Render("f:fill"))
|
||
|
||
case db.CardDecision:
|
||
if chose, ok := data["chose"].(string); ok && chose != "" {
|
||
b.WriteString(" " + detailLabelStyle.Render("chose: ") + detailValueStyle.Render(chose) + "\n")
|
||
}
|
||
if why, ok := data["why"].(string); ok && why != "" {
|
||
b.WriteString(" " + detailLabelStyle.Render("why: ") + detailValueStyle.Render(why) + "\n")
|
||
}
|
||
if rejected, ok := data["rejected"].([]interface{}); ok && len(rejected) > 0 {
|
||
items := make([]string, 0, len(rejected))
|
||
for _, r := range rejected {
|
||
if s, ok := r.(string); ok {
|
||
items = append(items, s)
|
||
}
|
||
}
|
||
if len(items) > 0 {
|
||
b.WriteString(" " + detailLabelStyle.Render("rejected: ") + detailValueStyle.Render(strings.Join(items, ", ")) + "\n")
|
||
}
|
||
}
|
||
|
||
case db.CardLink:
|
||
if url, ok := data["url"].(string); ok && url != "" {
|
||
b.WriteString(" " + detailLabelStyle.Render("↗ ") + detailValueStyle.Render(url) + "\n")
|
||
}
|
||
}
|
||
|
||
return b.String()
|
||
}
|