Files
nib-v1/internal/tui/commands.go
T
lerko 1e58433936
CI / test (pull_request) Successful in 2m27s
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.
2026-05-21 13:34:56 -04:00

345 lines
7.4 KiB
Go

package tui
import (
"context"
"os"
"os/exec"
"strings"
"time"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/carddata"
"github.com/lerko/nib/internal/db"
)
type entitiesLoadedMsg struct {
entities []*db.Entity
}
type entityCreatedMsg struct {
entity *db.Entity
}
type entityDeletedMsg struct {
id string
}
type entityUpdatedMsg struct {
entity *db.Entity
action string
}
type entityPromotedMsg struct {
id string
cardType db.CardType
}
type entityDemotedMsg struct {
id string
}
type entityCopiedMsg struct{}
type entityAbsorbedMsg struct {
targetID string
}
type absorbSourcesLoadedMsg struct {
targetID string
entities []*db.Entity
}
type stepsPersistedMsg struct{}
type templateCopiedMsg struct{}
type backlinksLoadedMsg struct {
backlinks []db.Backlink
}
type tagsLoadedMsg struct {
tags []db.TagCount
}
type railTagsLoadedMsg struct {
tags []db.TagCount
}
type staleEntitiesLoadedMsg struct {
entities []*db.Entity
}
type stumbleActionMsg struct {
action string
}
type statusClearMsg struct{ seq int }
type editorFinishedMsg struct {
err error
}
type errMsg struct {
err error
}
func loadEntities(store *db.Store, params db.ListParams) tea.Cmd {
return func() tea.Msg {
entities, err := store.List(context.Background(), params)
if err != nil {
return errMsg{err}
}
return entitiesLoadedMsg{entities}
}
}
func createEntity(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
if err := store.Create(context.Background(), e); err != nil {
return errMsg{err}
}
return entityCreatedMsg{e}
}
}
func deleteEntity(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if _, err := store.SoftDelete(context.Background(), id); err != nil {
return errMsg{err}
}
return entityDeletedMsg{id}
}
}
func toggleTodo(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
var update db.EntityUpdate
if e.CompletedAt == nil {
now := time.Now().UTC()
update = db.EntityUpdate{CompletedAt: &now}
} else {
update = db.EntityUpdate{ClearCompleted: true}
}
if err := store.Update(context.Background(), e.ID, &update); err != nil {
return errMsg{err}
}
updated, err := store.Get(context.Background(), e.ID)
if err != nil {
return errMsg{err}
}
action := "completed"
if e.CompletedAt != nil {
action = "reopened"
}
return entityUpdatedMsg{updated, action}
}
}
func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
newPinned := !e.Pinned
update := db.EntityUpdate{Pinned: &newPinned}
if err := store.Update(context.Background(), e.ID, &update); err != nil {
return errMsg{err}
}
updated, err := store.Get(context.Background(), e.ID)
if err != nil {
return errMsg{err}
}
action := "pinned"
if !newPinned {
action = "unpinned"
}
return entityUpdatedMsg{updated, action}
}
}
func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd {
return func() tea.Msg {
cd := carddata.GenerateCardData(ct, body)
if err := store.Promote(context.Background(), id, ct, cd); err != nil {
return errMsg{err}
}
return entityPromotedMsg{id, ct}
}
}
func demoteEntity(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if err := store.Demote(context.Background(), id); err != nil {
return errMsg{err}
}
return entityDemotedMsg{id}
}
}
func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd {
return func() tea.Msg {
if err := clipboard.WriteAll(e.Body); err != nil {
return errMsg{err}
}
if err := store.IncrementUse(context.Background(), e.ID); err != nil {
return errMsg{err}
}
return entityCopiedMsg{}
}
}
func loadTags(store *db.Store) tea.Cmd {
return func() tea.Msg {
tags, err := store.ListTags(context.Background(), false)
if err != nil {
return errMsg{err}
}
return tagsLoadedMsg{tags}
}
}
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)
if err != nil {
return errMsg{err}
}
return railTagsLoadedMsg{tags}
}
}
func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
editorEnv := os.Getenv("EDITOR")
if editorEnv == "" {
editorEnv = os.Getenv("VISUAL")
}
if editorEnv == "" {
editorEnv = "vi"
}
parts := strings.Fields(editorEnv)
editor, editorArgs := parts[0], parts[1:]
f, err := os.CreateTemp("", "nib-edit-*.md")
if err != nil {
return func() tea.Msg { return errMsg{err} }
}
if _, err := f.WriteString(e.Body); err != nil {
f.Close()
os.Remove(f.Name())
return func() tea.Msg { return errMsg{err} }
}
f.Close()
c := exec.Command(editor, append(editorArgs, f.Name())...)
return tea.ExecProcess(c, func(err error) tea.Msg {
defer os.Remove(f.Name())
if err != nil {
return editorFinishedMsg{err}
}
content, readErr := os.ReadFile(f.Name())
if readErr != nil {
return editorFinishedMsg{readErr}
}
newBody := string(content)
if newBody == e.Body {
return editorFinishedMsg{nil}
}
update := db.EntityUpdate{Body: &newBody}
if updateErr := store.Update(context.Background(), e.ID, &update); updateErr != nil {
return editorFinishedMsg{updateErr}
}
return editorFinishedMsg{nil}
})
}
func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd {
return func() tea.Msg {
entities, err := store.List(context.Background(), db.DefaultListParams())
if err != nil {
return errMsg{err}
}
return absorbSourcesLoadedMsg{targetID, entities}
}
}
func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
return func() tea.Msg {
if err := store.Absorb(context.Background(), targetID, sourceID); err != nil {
return errMsg{err}
}
return entityAbsorbedMsg{targetID}
}
}
func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd {
return func() tea.Msg {
update := db.EntityUpdate{CardData: &stepsJSON}
if err := store.Update(context.Background(), entityID, &update); err != nil {
return errMsg{err}
}
return stepsPersistedMsg{}
}
}
func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd {
return func() tea.Msg {
if err := clipboard.WriteAll(resolved); err != nil {
return errMsg{err}
}
if err := store.IncrementUse(context.Background(), entityID); err != nil {
return errMsg{err}
}
return templateCopiedMsg{}
}
}
func clearStatusAfter(d time.Duration, seq int) tea.Cmd {
return tea.Tick(d, func(time.Time) tea.Msg {
return statusClearMsg{seq: seq}
})
}
func loadStaleEntities(store *db.Store) tea.Cmd {
return func() tea.Msg {
entities, err := store.List(context.Background(), staleParams())
if err != nil {
return errMsg{err}
}
return staleEntitiesLoadedMsg{entities}
}
}
func stumbleDismiss(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
if _, err := store.SoftDelete(context.Background(), id); err != nil {
return errMsg{err}
}
return stumbleActionMsg{"dismissed"}
}
}
func stumblePin(store *db.Store, id string) tea.Cmd {
return func() tea.Msg {
pinned := true
update := db.EntityUpdate{Pinned: &pinned}
if err := store.Update(context.Background(), id, &update); err != nil {
return errMsg{err}
}
return stumbleActionMsg{"pinned"}
}
}