summaryrefslogtreecommitdiffstats
path: root/internal/tui/person_selector.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/person_selector.go')
-rw-r--r--internal/tui/person_selector.go426
1 files changed, 426 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
+}