diff options
| author | s <[email protected]> | 2025-11-30 00:34:34 -0500 |
|---|---|---|
| committer | s <[email protected]> | 2025-11-30 00:34:34 -0500 |
| commit | 45f72539c2a6638fd0e777fcd1e788a7084ff8b2 (patch) | |
| tree | d51191b9ce1007423ab5d9b778143025b25a44f6 /internal/tui | |
| parent | 7d6eb2f1a38e2265751cd61a43769959405866b4 (diff) | |
| download | dborg-45f72539c2a6638fd0e777fcd1e788a7084ff8b2.tar.gz dborg-45f72539c2a6638fd0e777fcd1e788a7084ff8b2.zip | |
feat: add interactive tui wizard for skiptrace person search and report generationv1.0.7
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/person_selector.go | 426 | ||||
| -rw-r--r-- | internal/tui/wizard.go | 478 |
2 files changed, 904 insertions, 0 deletions
diff --git a/internal/tui/person_selector.go b/internal/tui/person_selector.go new file mode 100644 index 0000000..2ae8caf --- /dev/null +++ b/internal/tui/person_selector.go @@ -0,0 +1,426 @@ +package tui + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("86")) + + selectedStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")) + + normalStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + dimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("243")) + + helpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")) +) + +type Person struct { + Index int + Name string + Age string + Location string + PhoneHint string + Raw map[string]interface{} +} + +type PersonSelectorModel struct { + people []Person + cursor int + selected int + quitting bool + err error + windowHeight int + offset int +} + +func NewPersonSelector(searchData map[string]interface{}) (*PersonSelectorModel, error) { + people, err := extractPeople(searchData) + if err != nil { + return nil, err + } + + if len(people) == 0 { + return nil, fmt.Errorf("no people found in search results") + } + + return &PersonSelectorModel{ + people: people, + cursor: 0, + selected: -1, + }, nil +} + +func extractPeople(data map[string]interface{}) ([]Person, error) { + results, ok := data["results"].([]interface{}) + if !ok { + if people, ok := data["people"].([]interface{}); ok { + results = people + } else if possiblePeople, ok := data["possible_people"].([]interface{}); ok { + results = possiblePeople + } else { + return nil, fmt.Errorf("no results, people, or possible_people found in response") + } + } + + var people []Person + for i, r := range results { + personData, ok := r.(map[string]interface{}) + if !ok { + continue + } + + p := Person{ + Index: i + 1, + Raw: personData, + } + + if names, ok := personData["names"].([]interface{}); ok && len(names) > 0 { + if nameData, ok := names[0].(map[string]interface{}); ok { + if display, ok := nameData["display"].(string); ok { + p.Name = display + } + } + } + + if p.Name == "" { + if name, ok := personData["name"].(string); ok { + p.Name = name + } + } + + if p.Name == "" { + p.Name = fmt.Sprintf("Person %d", i+1) + } + + p.Age = extractAge(personData) + p.Location = extractLocation(personData) + p.PhoneHint = extractPhoneHint(personData) + + people = append(people, p) + } + + return people, nil +} + +func extractAge(personData map[string]interface{}) string { + if age, ok := personData["age"].(float64); ok && age > 0 { + return fmt.Sprintf("%d", int(age)) + } + + if age, ok := personData["age"].(string); ok && age != "" { + return age + } + + if dob, ok := personData["dob"].(map[string]interface{}); ok { + if age, ok := dob["age"].(float64); ok && age > 0 { + return fmt.Sprintf("%d", int(age)) + } + if display, ok := dob["display"].(string); ok && display != "" { + return display + } + } + + if dob, ok := personData["dob"].(string); ok && dob != "" { + return dob + } + + return "" +} + +func extractLocation(personData map[string]interface{}) string { + if location, ok := personData["location"].(string); ok && location != "" { + return location + } + + if addresses, ok := personData["addresses"].([]interface{}); ok && len(addresses) > 0 { + if addr, ok := addresses[0].(map[string]interface{}); ok { + if display, ok := addr["display"].(string); ok && display != "" { + return display + } + var parts []string + if city, ok := addr["city"].(string); ok && city != "" { + parts = append(parts, city) + } + if state, ok := addr["state"].(string); ok && state != "" { + parts = append(parts, state) + } + if len(parts) > 0 { + return strings.Join(parts, ", ") + } + } + } + + if address, ok := personData["address"].(map[string]interface{}); ok { + if display, ok := address["display"].(string); ok && display != "" { + return display + } + var parts []string + if city, ok := address["city"].(string); ok && city != "" { + parts = append(parts, city) + } + if state, ok := address["state"].(string); ok && state != "" { + parts = append(parts, state) + } + if len(parts) > 0 { + return strings.Join(parts, ", ") + } + } + + return "" +} + +func extractPhoneHint(personData map[string]interface{}) string { + if phones, ok := personData["phones"].(string); ok && phones != "" { + return phones + } + + if phoneHint, ok := personData["phone_hint"].(string); ok && phoneHint != "" { + return phoneHint + } + + if phones, ok := personData["phones"].([]interface{}); ok && len(phones) > 0 { + if phone, ok := phones[0].(map[string]interface{}); ok { + if display, ok := phone["display"].(string); ok && display != "" { + return maskPhone(display) + } + if number, ok := phone["number"].(string); ok && number != "" { + return maskPhone(number) + } + if hint, ok := phone["display_international"].(string); ok && hint != "" { + return maskPhone(hint) + } + } + if phoneStr, ok := phones[0].(string); ok && phoneStr != "" { + return maskPhone(phoneStr) + } + } + + return "" +} + +func maskPhone(phone string) string { + digits := "" + for _, c := range phone { + if c >= '0' && c <= '9' { + digits += string(c) + } + } + + if len(digits) >= 10 { + last4 := digits[len(digits)-4:] + return "***-***-" + last4 + } + + if len(digits) >= 4 { + return "***-" + digits[len(digits)-4:] + } + + return phone +} + +func (m PersonSelectorModel) Init() tea.Cmd { + return nil +} + +func (m PersonSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.windowHeight = msg.Height - 5 + if m.windowHeight < 3 { + m.windowHeight = 3 + } + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + m.quitting = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + if m.cursor < m.offset { + m.offset = m.cursor + } + } + case "down", "j": + if m.cursor < len(m.people)-1 { + m.cursor++ + visibleHeight := m.visibleHeight() + if m.cursor >= m.offset+visibleHeight { + m.offset = m.cursor - visibleHeight + 1 + } + } + case "enter", " ": + m.selected = m.cursor + return m, tea.Quit + case "home": + m.cursor = 0 + m.offset = 0 + case "end": + m.cursor = len(m.people) - 1 + visibleHeight := m.visibleHeight() + if len(m.people) > visibleHeight { + m.offset = len(m.people) - visibleHeight + } + case "pgup": + visibleHeight := m.visibleHeight() + m.cursor -= visibleHeight + if m.cursor < 0 { + m.cursor = 0 + } + m.offset -= visibleHeight + if m.offset < 0 { + m.offset = 0 + } + case "pgdown": + visibleHeight := m.visibleHeight() + m.cursor += visibleHeight + if m.cursor >= len(m.people) { + m.cursor = len(m.people) - 1 + } + m.offset += visibleHeight + maxOffset := len(m.people) - visibleHeight + if maxOffset < 0 { + maxOffset = 0 + } + if m.offset > maxOffset { + m.offset = maxOffset + } + } + } + return m, nil +} + +func (m PersonSelectorModel) visibleHeight() int { + if m.windowHeight > 0 { + return m.windowHeight + } + return 20 +} + +func (m PersonSelectorModel) View() string { + if m.quitting && m.selected == -1 { + return "" + } + + var b strings.Builder + + b.WriteString(titleStyle.Render("Select a person to generate report:")) + b.WriteString("\n\n") + + visibleHeight := m.visibleHeight() + endIdx := m.offset + visibleHeight + if endIdx > len(m.people) { + endIdx = len(m.people) + } + + if m.offset > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ↑ %d more above\n", m.offset))) + } + + for i := m.offset; i < endIdx; i++ { + p := m.people[i] + cursor := " " + style := normalStyle + + if i == m.cursor { + cursor = "> " + style = selectedStyle + } + + line := fmt.Sprintf("%s%d. %s", cursor, p.Index, p.Name) + + details := []string{} + if p.Age != "" { + details = append(details, fmt.Sprintf("Age: %s", p.Age)) + } + if p.Location != "" { + details = append(details, p.Location) + } + if p.PhoneHint != "" { + details = append(details, fmt.Sprintf("Phone: %s", p.PhoneHint)) + } + + if len(details) > 0 { + line += dimStyle.Render(fmt.Sprintf(" (%s)", strings.Join(details, ", "))) + } + + b.WriteString(style.Render(line)) + b.WriteString("\n") + } + + remaining := len(m.people) - endIdx + if remaining > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ↓ %d more below\n", remaining))) + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/k up • ↓/j down • pgup/pgdn • home/end • enter select • q quit")) + + return b.String() +} + +func (m PersonSelectorModel) Selected() int { + return m.selected +} + +func (m PersonSelectorModel) WasQuit() bool { + return m.quitting && m.selected == -1 +} + +func RunPersonSelector(searchData map[string]interface{}) (int, error) { + model, err := NewPersonSelector(searchData) + if err != nil { + return -1, err + } + + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + return -1, fmt.Errorf("failed to open terminal: %w", err) + } + defer tty.Close() + + p := tea.NewProgram(model, tea.WithInput(tty), tea.WithOutput(tty)) + + finalModel, err := p.Run() + if err != nil { + return -1, fmt.Errorf("failed to run selector: %w", err) + } + + m, ok := finalModel.(PersonSelectorModel) + if !ok { + return -1, fmt.Errorf("unexpected model type") + } + + if m.WasQuit() { + return -1, fmt.Errorf("selection cancelled") + } + + return m.Selected() + 1, nil +} + +func FormatReportJSON(reportData map[string]interface{}) (string, error) { + output, err := json.MarshalIndent(reportData, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal report: %w", err) + } + return string(output), nil +} diff --git a/internal/tui/wizard.go b/internal/tui/wizard.go new file mode 100644 index 0000000..34ba70e --- /dev/null +++ b/internal/tui/wizard.go @@ -0,0 +1,478 @@ +package tui + +import ( + "fmt" + "os" + "strings" + + "git.db.org.ai/dborg/internal/formatter" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type wizardState int + +const ( + stateForm wizardState = iota + stateSearching + stateResults + stateGenerating + stateDone + stateError +) + +type SearchFunc func(firstName, lastName, city, state, age string) (map[string]interface{}, string, error) +type ReportFunc func(sxKey string, selection int) (map[string]interface{}, error) + +type WizardModel struct { + state wizardState + inputs []textinput.Model + focusIndex int + table table.Model + people []Person + selectedIdx int + sxKey string + reportData map[string]interface{} + err error + searchFn SearchFunc + reportFn ReportFunc + windowWidth int + windowHeight int + jsonOutput bool +} + +var ( + focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + cursorStyle = focusedStyle + noStyle = lipgloss.NewStyle() + + focusedButton = lipgloss.NewStyle(). + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Padding(0, 2) + + blurredButton = lipgloss.NewStyle(). + Foreground(lipgloss.Color("243")). + Padding(0, 2) + + wizardTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("86")). + MarginBottom(1) + + tableStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")) + + statusStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Italic(true) +) + +func NewWizardModel(searchFn SearchFunc, reportFn ReportFunc, jsonOutput bool) WizardModel { + inputs := make([]textinput.Model, 5) + + inputs[0] = textinput.New() + inputs[0].Placeholder = "John" + inputs[0].Focus() + inputs[0].CharLimit = 50 + inputs[0].Width = 30 + inputs[0].Prompt = "First Name: " + inputs[0].PromptStyle = focusedStyle + inputs[0].TextStyle = focusedStyle + + inputs[1] = textinput.New() + inputs[1].Placeholder = "Doe" + inputs[1].CharLimit = 50 + inputs[1].Width = 30 + inputs[1].Prompt = "Last Name: " + inputs[1].PromptStyle = blurredStyle + inputs[1].TextStyle = blurredStyle + + inputs[2] = textinput.New() + inputs[2].Placeholder = "New York" + inputs[2].CharLimit = 50 + inputs[2].Width = 30 + inputs[2].Prompt = "City: " + inputs[2].PromptStyle = blurredStyle + inputs[2].TextStyle = blurredStyle + + inputs[3] = textinput.New() + inputs[3].Placeholder = "NY" + inputs[3].CharLimit = 2 + inputs[3].Width = 10 + inputs[3].Prompt = "State: " + inputs[3].PromptStyle = blurredStyle + inputs[3].TextStyle = blurredStyle + + inputs[4] = textinput.New() + inputs[4].Placeholder = "35" + inputs[4].CharLimit = 3 + inputs[4].Width = 10 + inputs[4].Prompt = "Age: " + inputs[4].PromptStyle = blurredStyle + inputs[4].TextStyle = blurredStyle + + return WizardModel{ + state: stateForm, + inputs: inputs, + focusIndex: 0, + searchFn: searchFn, + reportFn: reportFn, + jsonOutput: jsonOutput, + } +} + +type searchResultMsg struct { + data map[string]interface{} + sxKey string + err error +} + +type reportResultMsg struct { + data map[string]interface{} + err error +} + +func (m WizardModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m WizardModel) doSearch() tea.Cmd { + return func() tea.Msg { + data, sxKey, err := m.searchFn( + m.inputs[0].Value(), + m.inputs[1].Value(), + m.inputs[2].Value(), + strings.ToUpper(m.inputs[3].Value()), + m.inputs[4].Value(), + ) + if err != nil { + return searchResultMsg{err: err} + } + return searchResultMsg{data: data, sxKey: sxKey} + } +} + +func (m WizardModel) doReport() tea.Cmd { + return func() tea.Msg { + data, err := m.reportFn(m.sxKey, m.selectedIdx+1) + if err != nil { + return reportResultMsg{err: err} + } + return reportResultMsg{data: data} + } +} + +func (m WizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.windowWidth = msg.Width + m.windowHeight = msg.Height + + case tea.KeyMsg: + switch m.state { + case stateForm: + return m.updateForm(msg) + case stateResults: + return m.updateResults(msg) + case stateError: + if msg.String() == "q" || msg.String() == "ctrl+c" || msg.String() == "enter" { + return m, tea.Quit + } + case stateDone: + return m, tea.Quit + } + + case searchResultMsg: + if msg.err != nil { + m.state = stateError + m.err = msg.err + return m, nil + } + + people, err := extractPeople(msg.data) + if err != nil { + m.state = stateError + m.err = err + return m, nil + } + + if len(people) == 0 { + m.state = stateError + m.err = fmt.Errorf("no results found") + return m, nil + } + + m.people = people + m.sxKey = msg.sxKey + m.table = m.buildTable() + m.state = stateResults + return m, nil + + case reportResultMsg: + if msg.err != nil { + m.state = stateError + m.err = msg.err + return m, nil + } + + m.reportData = msg.data + m.state = stateDone + return m, tea.Quit + } + + return m, nil +} + +func (m WizardModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + + case "tab", "down": + m.focusIndex++ + if m.focusIndex > len(m.inputs) { + m.focusIndex = 0 + } + return m.updateFocus(), nil + + case "shift+tab", "up": + m.focusIndex-- + if m.focusIndex < 0 { + m.focusIndex = len(m.inputs) + } + return m.updateFocus(), nil + + case "enter": + if m.focusIndex == len(m.inputs) { + if m.inputs[0].Value() == "" || m.inputs[1].Value() == "" { + m.err = fmt.Errorf("first name and last name are required") + return m, nil + } + m.state = stateSearching + m.err = nil + return m, m.doSearch() + } + m.focusIndex++ + if m.focusIndex > len(m.inputs) { + m.focusIndex = len(m.inputs) + } + return m.updateFocus(), nil + } + + cmd := m.updateInputs(msg) + return m, cmd +} + +func (m WizardModel) updateResults(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + + case "esc": + m.state = stateForm + m.focusIndex = 0 + return m.updateFocus(), nil + + case "enter": + m.selectedIdx = m.table.Cursor() + m.state = stateGenerating + return m, m.doReport() + } + + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +func (m *WizardModel) updateFocus() WizardModel { + for i := range m.inputs { + if i == m.focusIndex { + m.inputs[i].Focus() + m.inputs[i].PromptStyle = focusedStyle + m.inputs[i].TextStyle = focusedStyle + } else { + m.inputs[i].Blur() + m.inputs[i].PromptStyle = blurredStyle + m.inputs[i].TextStyle = blurredStyle + } + } + return *m +} + +func (m *WizardModel) updateInputs(msg tea.KeyMsg) tea.Cmd { + cmds := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + m.inputs[i], cmds[i] = m.inputs[i].Update(msg) + } + return tea.Batch(cmds...) +} + +func (m WizardModel) buildTable() table.Model { + columns := []table.Column{ + {Title: "#", Width: 4}, + {Title: "Name", Width: 25}, + {Title: "Age", Width: 6}, + {Title: "Location", Width: 25}, + {Title: "Phone Hint", Width: 15}, + } + + rows := make([]table.Row, len(m.people)) + for i, p := range m.people { + rows[i] = table.Row{ + fmt.Sprintf("%d", p.Index), + p.Name, + p.Age, + p.Location, + p.PhoneHint, + } + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(min(len(rows)+1, 15)), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + return t +} + +func (m WizardModel) View() string { + switch m.state { + case stateForm: + return m.viewForm() + case stateSearching: + return m.viewSearching() + case stateResults: + return m.viewResults() + case stateGenerating: + return m.viewGenerating() + case stateError: + return m.viewError() + case stateDone: + return "" + } + return "" +} + +func (m WizardModel) viewForm() string { + var b strings.Builder + + b.WriteString(wizardTitleStyle.Render("Skiptrace Person Search")) + b.WriteString("\n\n") + + for i := range m.inputs { + b.WriteString(m.inputs[i].View()) + b.WriteString("\n") + } + + b.WriteString("\n") + + button := blurredButton.Render("[ Search ]") + if m.focusIndex == len(m.inputs) { + button = focusedButton.Render("[ Search ]") + } + b.WriteString(button) + + if m.err != nil { + b.WriteString("\n\n") + b.WriteString(errorStyle.Render(m.err.Error())) + } + + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("tab/shift+tab: navigate | enter: submit | esc: quit")) + + return b.String() +} + +func (m WizardModel) viewSearching() string { + return wizardTitleStyle.Render("Skiptrace Person Search") + "\n\n" + + statusStyle.Render("Searching...") +} + +func (m WizardModel) viewResults() string { + var b strings.Builder + + b.WriteString(wizardTitleStyle.Render("Select a Person")) + b.WriteString("\n") + b.WriteString(statusStyle.Render(fmt.Sprintf("Found %d results", len(m.people)))) + b.WriteString("\n\n") + + b.WriteString(tableStyle.Render(m.table.View())) + + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("j/k or arrows: navigate | enter: select | esc: back | q: quit")) + + return b.String() +} + +func (m WizardModel) viewGenerating() string { + return wizardTitleStyle.Render("Generating Report") + "\n\n" + + statusStyle.Render("Please wait, this may take a moment...") +} + +func (m WizardModel) viewError() string { + return wizardTitleStyle.Render("Error") + "\n\n" + + errorStyle.Render(m.err.Error()) + "\n\n" + + helpStyle.Render("press enter or q to exit") +} + +func (m WizardModel) GetReportData() map[string]interface{} { + return m.reportData +} + +func RunSkiptraceWizard(searchFn SearchFunc, reportFn ReportFunc, jsonOutput bool) error { + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + return fmt.Errorf("failed to open terminal: %w", err) + } + defer tty.Close() + + model := NewWizardModel(searchFn, reportFn, jsonOutput) + p := tea.NewProgram(model, tea.WithInput(tty), tea.WithOutput(tty)) + + finalModel, err := p.Run() + if err != nil { + return fmt.Errorf("wizard error: %w", err) + } + + m, ok := finalModel.(WizardModel) + if !ok { + return fmt.Errorf("unexpected model type") + } + + if m.state == stateError { + return m.err + } + + if m.reportData != nil { + if jsonOutput { + output, err := FormatReportJSON(m.reportData) + if err != nil { + return err + } + fmt.Println(output) + } else { + fmt.Println(formatter.FormatSkiptraceReport(m.reportData)) + } + } + + return nil +} |
