summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--cmd/services.go2
-rw-r--r--cmd/skiptrace.go111
-rw-r--r--go.mod25
-rw-r--r--go.sum51
-rw-r--r--internal/formatter/skiptrace.go1289
-rw-r--r--internal/tui/person_selector.go426
-rw-r--r--internal/tui/wizard.go478
7 files changed, 2358 insertions, 24 deletions
diff --git a/cmd/services.go b/cmd/services.go
index 56c2149..93e447c 100644
--- a/cmd/services.go
+++ b/cmd/services.go
@@ -40,7 +40,7 @@ func runServices(cmd *cobra.Command, args []string) error {
}
fmt.Println("\nAvailable Services:")
- fmt.Println("===================\n")
+ fmt.Println("===================")
servicesData, _ := json.MarshalIndent(services, "", " ")
fmt.Println(string(servicesData))
diff --git a/cmd/skiptrace.go b/cmd/skiptrace.go
index 0f89ed6..d8c439a 100644
--- a/cmd/skiptrace.go
+++ b/cmd/skiptrace.go
@@ -2,11 +2,11 @@ package cmd
import (
"fmt"
- "strconv"
"git.db.org.ai/dborg/internal/client"
"git.db.org.ai/dborg/internal/formatter"
"git.db.org.ai/dborg/internal/models"
+ "git.db.org.ai/dborg/internal/tui"
"github.com/spf13/cobra"
)
@@ -19,6 +19,14 @@ Note: All skiptrace commands require a premium API key. If you receive a 403 err
contact support to upgrade your account for premium access.`,
}
+var skiptraceWizardCmd = &cobra.Command{
+ Use: "wizard",
+ Short: "Interactive wizard to search for a person and generate a report",
+ Long: `Launch an interactive wizard with a form to enter search criteria,
+then select from results in a table view to generate a detailed report.`,
+ RunE: runSkiptraceWizard,
+}
+
var skiptracePeopleCmd = &cobra.Command{
Use: "people",
Short: "Search for people by name",
@@ -26,14 +34,6 @@ var skiptracePeopleCmd = &cobra.Command{
RunE: runSkiptracePeople,
}
-var skiptraceReportCmd = &cobra.Command{
- Use: "report [sx_key] [selection]",
- Short: "Get detailed report for selected person",
- Long: `Retrieve detailed report for a person from previous search results using sx_key and selection number`,
- Args: cobra.ExactArgs(2),
- RunE: runSkiptraceReport,
-}
-
var skiptracePhoneCmd = &cobra.Command{
Use: "phone [phone_number]",
Short: "Search for phone number",
@@ -50,12 +50,21 @@ var skiptraceEmailCmd = &cobra.Command{
RunE: runSkiptraceEmail,
}
+var skiptraceReportCmd = &cobra.Command{
+ Use: "report [sx_key] [selection]",
+ Short: "Get a person report by sx_key and selection number",
+ Long: `Generate a detailed report for a person using the sx_key from a people search and selection number`,
+ Args: cobra.ExactArgs(2),
+ RunE: runSkiptraceReport,
+}
+
func init() {
rootCmd.AddCommand(skiptraceCmd)
skiptraceCmd.AddCommand(skiptracePeopleCmd)
- skiptraceCmd.AddCommand(skiptraceReportCmd)
skiptraceCmd.AddCommand(skiptracePhoneCmd)
skiptraceCmd.AddCommand(skiptraceEmailCmd)
+ skiptraceCmd.AddCommand(skiptraceWizardCmd)
+ skiptraceCmd.AddCommand(skiptraceReportCmd)
skiptracePeopleCmd.Flags().StringP("first-name", "f", "", "First name (required)")
skiptracePeopleCmd.Flags().StringP("last-name", "l", "", "Last name (required)")
@@ -64,6 +73,8 @@ func init() {
skiptracePeopleCmd.Flags().StringP("age", "a", "", "Age")
skiptracePeopleCmd.MarkFlagRequired("first-name")
skiptracePeopleCmd.MarkFlagRequired("last-name")
+
+ skiptraceWizardCmd.Flags().BoolP("json", "j", false, "Output raw JSON instead of formatted display")
}
func getSkiptraceClient(cmd *cobra.Command) (*client.Client, error) {
@@ -100,19 +111,13 @@ func runSkiptracePeople(cmd *cobra.Command, args []string) error {
return nil
}
-func runSkiptraceReport(cmd *cobra.Command, args []string) error {
+func runSkiptracePhone(cmd *cobra.Command, args []string) error {
c, err := getSkiptraceClient(cmd)
if err != nil {
return err
}
- sxKey := args[0]
- selection, err := strconv.Atoi(args[1])
- if err != nil {
- return fmt.Errorf("invalid selection number: %s", args[1])
- }
-
- response, err := c.GetPersonReport(sxKey, selection)
+ response, err := c.SearchPhone(args[0])
if err != nil {
return err
}
@@ -129,13 +134,13 @@ func runSkiptraceReport(cmd *cobra.Command, args []string) error {
return nil
}
-func runSkiptracePhone(cmd *cobra.Command, args []string) error {
+func runSkiptraceEmail(cmd *cobra.Command, args []string) error {
c, err := getSkiptraceClient(cmd)
if err != nil {
return err
}
- response, err := c.SearchPhone(args[0])
+ response, err := c.SearchEmail(args[0])
if err != nil {
return err
}
@@ -152,13 +157,23 @@ func runSkiptracePhone(cmd *cobra.Command, args []string) error {
return nil
}
-func runSkiptraceEmail(cmd *cobra.Command, args []string) error {
+func runSkiptraceReport(cmd *cobra.Command, args []string) error {
c, err := getSkiptraceClient(cmd)
if err != nil {
return err
}
- response, err := c.SearchEmail(args[0])
+ sxKey := args[0]
+ selection := 0
+ if _, err := fmt.Sscanf(args[1], "%d", &selection); err != nil {
+ return fmt.Errorf("invalid selection number: %s", args[1])
+ }
+
+ if selection <= 0 {
+ return fmt.Errorf("selection must be a positive integer")
+ }
+
+ response, err := c.GetPersonReport(sxKey, selection)
if err != nil {
return err
}
@@ -174,3 +189,55 @@ func runSkiptraceEmail(cmd *cobra.Command, args []string) error {
printOutput(output)
return nil
}
+
+func runSkiptraceWizard(cmd *cobra.Command, args []string) error {
+ c, err := getSkiptraceClient(cmd)
+ if err != nil {
+ return err
+ }
+
+ jsonOutput, _ := cmd.Flags().GetBool("json")
+
+ searchFn := func(firstName, lastName, city, state, age string) (map[string]interface{}, string, error) {
+ params := &models.SkiptraceParams{
+ FirstName: firstName,
+ LastName: lastName,
+ City: city,
+ State: state,
+ Age: age,
+ }
+
+ response, err := c.SearchPeople(params)
+ if err != nil {
+ return nil, "", err
+ }
+
+ if response.Error != "" {
+ return nil, "", fmt.Errorf("%s", response.Error)
+ }
+
+ sxKey := response.SXKey
+ if sxKey == "" {
+ if key, ok := response.Data["sx_key"].(string); ok {
+ sxKey = key
+ }
+ }
+
+ return response.Data, sxKey, nil
+ }
+
+ reportFn := func(sxKey string, selection int) (map[string]interface{}, error) {
+ response, err := c.GetPersonReport(sxKey, selection)
+ if err != nil {
+ return nil, err
+ }
+
+ if response.Error != "" {
+ return nil, fmt.Errorf("%s", response.Error)
+ }
+
+ return response.Data, nil
+ }
+
+ return tui.RunSkiptraceWizard(searchFn, reportFn, jsonOutput)
+}
diff --git a/go.mod b/go.mod
index 7ba7e80..8300a5f 100644
--- a/go.mod
+++ b/go.mod
@@ -2,9 +2,32 @@ module git.db.org.ai/dborg
go 1.24.4
-require github.com/spf13/cobra v1.10.1
+require (
+ github.com/charmbracelet/bubbles v0.21.0
+ github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/lipgloss v1.1.0
+ github.com/spf13/cobra v1.10.1
+)
require (
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+ github.com/charmbracelet/x/ansi v0.10.1 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+ github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sys v0.36.0 // indirect
+ golang.org/x/text v0.3.8 // indirect
)
diff --git a/go.sum b/go.sum
index e613680..712f639 100644
--- a/go.sum
+++ b/go.sum
@@ -1,10 +1,61 @@
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
+github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
+github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
+github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
+github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/formatter/skiptrace.go b/internal/formatter/skiptrace.go
index 26c0a0f..dd5eccb 100644
--- a/internal/formatter/skiptrace.go
+++ b/internal/formatter/skiptrace.go
@@ -5,6 +5,7 @@ import (
"strings"
"git.db.org.ai/dborg/internal/models"
+ "github.com/charmbracelet/lipgloss"
)
func FormatSkiptraceResults(resp interface{}, asJSON bool) (string, error) {
@@ -126,3 +127,1291 @@ func formatAddress(sb *strings.Builder, addr map[string]interface{}) {
sb.WriteString(fmt.Sprintf(" %s %s\n", Dim("•"), addrStr.String()))
}
}
+
+var (
+ reportTitleStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color("86")).
+ MarginBottom(1)
+
+ reportSectionStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color("212")).
+ MarginTop(1)
+
+ reportSubsectionStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color("229"))
+
+ reportLabelStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("117"))
+
+ reportValueStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("252"))
+
+ reportDimStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("243"))
+
+ reportHighlightStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("156"))
+
+ reportWarningStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("214"))
+
+ reportDividerStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("240"))
+)
+
+func FormatSkiptraceReport(data map[string]interface{}) string {
+ var sb strings.Builder
+
+ sb.WriteString(reportTitleStyle.Render("━━━ SKIPTRACE REPORT ━━━"))
+ sb.WriteString("\n\n")
+
+ formatReportBasicInfo(&sb, data)
+ formatReportGender(&sb, data)
+ formatReportNames(&sb, data)
+ formatReportDOB(&sb, data)
+ formatReportUsernames(&sb, data)
+ formatReportAddresses(&sb, data)
+ formatReportPhones(&sb, data)
+ formatReportEmails(&sb, data)
+ formatReportURLs(&sb, data)
+ formatReportOtherProfiles(&sb, data)
+ formatReportAssociates(&sb, data)
+ formatReportRelatives(&sb, data)
+ formatReportRelatedPersons(&sb, data)
+ formatReportPossibleAssociates(&sb, data)
+ formatReportEmployment(&sb, data)
+ formatReportEducation(&sb, data)
+ formatReportProperties(&sb, data)
+ formatReportVehicles(&sb, data)
+ formatReportCriminalRecords(&sb, data)
+ formatReportVoterRecords(&sb, data)
+ formatReportSocialProfiles(&sb, data)
+ formatReportImages(&sb, data)
+ formatReportTags(&sb, data)
+
+ sb.WriteString("\n")
+ sb.WriteString(reportDividerStyle.Render(strings.Repeat("━", 50)))
+ sb.WriteString("\n")
+
+ return sb.String()
+}
+
+func formatReportBasicInfo(sb *strings.Builder, data map[string]interface{}) {
+ if person, ok := data["person"].(map[string]interface{}); ok {
+ sb.WriteString(reportSectionStyle.Render("▸ BASIC INFORMATION"))
+ sb.WriteString("\n")
+
+ if name, ok := person["name"].(string); ok && name != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportLabelStyle.Render("Name:"), reportValueStyle.Render(name)))
+ }
+ if gender, ok := person["gender"].(string); ok && gender != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportLabelStyle.Render("Gender:"), reportValueStyle.Render(gender)))
+ }
+ sb.WriteString("\n")
+ }
+}
+
+func formatReportGender(sb *strings.Builder, data map[string]interface{}) {
+ gender, ok := data["gender"].(map[string]interface{})
+ if !ok {
+ return
+ }
+
+ content, ok := gender["content"].(string)
+ if !ok || content == "" {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ GENDER"))
+ sb.WriteString("\n")
+ sb.WriteString(fmt.Sprintf(" %s %s\n\n", reportLabelStyle.Render("Gender:"), reportValueStyle.Render(content)))
+}
+
+func formatReportNames(sb *strings.Builder, data map[string]interface{}) {
+ names, ok := data["names"].([]interface{})
+ if !ok || len(names) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ NAMES / ALIASES"))
+ sb.WriteString("\n")
+
+ for _, n := range names {
+ nameMap, ok := n.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ var nameParts []string
+ if first, ok := nameMap["first"].(string); ok && first != "" {
+ nameParts = append(nameParts, first)
+ }
+ if middle, ok := nameMap["middle"].(string); ok && middle != "" {
+ nameParts = append(nameParts, middle)
+ }
+ if last, ok := nameMap["last"].(string); ok && last != "" {
+ nameParts = append(nameParts, last)
+ }
+
+ if display, ok := nameMap["display"].(string); ok && display != "" {
+ nameParts = []string{display}
+ }
+
+ if len(nameParts) > 0 {
+ nameStr := strings.Join(nameParts, " ")
+ typeStr := ""
+ if nameType, ok := nameMap["type"].(string); ok && nameType != "" {
+ typeStr = reportDimStyle.Render(fmt.Sprintf(" (%s)", nameType))
+ }
+ sb.WriteString(fmt.Sprintf(" %s %s%s\n", reportDimStyle.Render("•"), reportValueStyle.Render(nameStr), typeStr))
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportDOB(sb *strings.Builder, data map[string]interface{}) {
+ dob, ok := data["dob"].(map[string]interface{})
+ if !ok {
+ if dobStr, ok := data["dob"].(string); ok && dobStr != "" {
+ sb.WriteString(reportSectionStyle.Render("▸ DATE OF BIRTH"))
+ sb.WriteString("\n")
+ sb.WriteString(fmt.Sprintf(" %s %s\n\n", reportLabelStyle.Render("DOB:"), reportValueStyle.Render(dobStr)))
+ }
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ DATE OF BIRTH"))
+ sb.WriteString("\n")
+
+ if display, ok := dob["display"].(string); ok && display != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportLabelStyle.Render("Date:"), reportValueStyle.Render(display)))
+ }
+ if age, ok := dob["age"].(float64); ok && age > 0 {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportLabelStyle.Render("Age:"), reportValueStyle.Render(fmt.Sprintf("%d", int(age)))))
+ }
+ if zodiac, ok := dob["zodiac"].(string); ok && zodiac != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportLabelStyle.Render("Zodiac:"), reportValueStyle.Render(zodiac)))
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportUsernames(sb *strings.Builder, data map[string]interface{}) {
+ usernames, ok := data["usernames"].([]interface{})
+ if !ok || len(usernames) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ USERNAMES"))
+ sb.WriteString("\n")
+
+ for _, u := range usernames {
+ if username, ok := u.(string); ok && username != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("•"), reportHighlightStyle.Render(username)))
+ } else if usernameMap, ok := u.(map[string]interface{}); ok {
+ if name, ok := usernameMap["username"].(string); ok && name != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportHighlightStyle.Render(name)))
+ if source, ok := usernameMap["source"].(string); ok && source != "" {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", source)))
+ }
+ sb.WriteString("\n")
+ }
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportAddresses(sb *strings.Builder, data map[string]interface{}) {
+ addresses, ok := data["addresses"].([]interface{})
+ if !ok || len(addresses) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ ADDRESSES"))
+ sb.WriteString("\n")
+
+ var current, historical []map[string]interface{}
+ for _, a := range addresses {
+ addrMap, ok := a.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ if isCurrent, ok := addrMap["current"].(bool); ok && isCurrent {
+ current = append(current, addrMap)
+ } else {
+ historical = append(historical, addrMap)
+ }
+ }
+
+ if len(current) > 0 {
+ sb.WriteString(reportSubsectionStyle.Render(" Current:"))
+ sb.WriteString("\n")
+ for _, addr := range current {
+ formatReportAddress(sb, addr, " ")
+ }
+ }
+
+ if len(historical) > 0 {
+ sb.WriteString(reportSubsectionStyle.Render(" Historical:"))
+ sb.WriteString("\n")
+ for i, addr := range historical {
+ if i >= 5 {
+ sb.WriteString(fmt.Sprintf(" %s\n", reportDimStyle.Render(fmt.Sprintf("... and %d more", len(historical)-5))))
+ break
+ }
+ formatReportAddress(sb, addr, " ")
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportAddress(sb *strings.Builder, addr map[string]interface{}, indent string) {
+ if display, ok := addr["display"].(string); ok && display != "" {
+ sb.WriteString(fmt.Sprintf("%s%s %s", indent, reportDimStyle.Render("•"), reportValueStyle.Render(display)))
+ } else {
+ var parts []string
+ if street, ok := addr["street"].(string); ok && street != "" {
+ parts = append(parts, street)
+ }
+ 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 zip, ok := addr["zip"].(string); ok && zip != "" {
+ parts = append(parts, zip)
+ }
+ if len(parts) > 0 {
+ sb.WriteString(fmt.Sprintf("%s%s %s", indent, reportDimStyle.Render("•"), reportValueStyle.Render(strings.Join(parts, ", "))))
+ }
+ }
+
+ var meta []string
+ if addrType, ok := addr["type"].(string); ok && addrType != "" {
+ meta = append(meta, addrType)
+ }
+ if firstSeen, ok := addr["first_seen"].(string); ok && firstSeen != "" {
+ meta = append(meta, fmt.Sprintf("since %s", firstSeen))
+ }
+ if lastSeen, ok := addr["last_seen"].(string); ok && lastSeen != "" {
+ meta = append(meta, fmt.Sprintf("until %s", lastSeen))
+ }
+ if len(meta) > 0 {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", strings.Join(meta, ", "))))
+ }
+ sb.WriteString("\n")
+
+ detailIndent := indent + " "
+
+ if county, ok := addr["county"].(string); ok && county != "" {
+ sb.WriteString(fmt.Sprintf("%s%s %s\n", detailIndent, reportDimStyle.Render("County:"), reportValueStyle.Render(county)))
+ }
+
+ hasCoords := false
+ var lat, lon float64
+ if l, ok := addr["latitude"].(float64); ok {
+ lat = l
+ hasCoords = true
+ }
+ if l, ok := addr["longitude"].(float64); ok {
+ lon = l
+ hasCoords = hasCoords && true
+ }
+ if hasCoords && (lat != 0 || lon != 0) {
+ sb.WriteString(fmt.Sprintf("%s%s %.6f, %.6f\n", detailIndent, reportDimStyle.Render("Coords:"), lat, lon))
+ }
+
+ if countyFips, ok := addr["county_fips"].(string); ok && countyFips != "" {
+ sb.WriteString(fmt.Sprintf("%s%s %s\n", detailIndent, reportDimStyle.Render("FIPS:"), reportValueStyle.Render(countyFips)))
+ }
+ if congDistrict, ok := addr["congressional_district"].(string); ok && congDistrict != "" {
+ sb.WriteString(fmt.Sprintf("%s%s %s\n", detailIndent, reportDimStyle.Render("Congressional District:"), reportValueStyle.Render(congDistrict)))
+ }
+}
+
+func formatReportPhones(sb *strings.Builder, data map[string]interface{}) {
+ phones, ok := data["phones"].([]interface{})
+ if !ok || len(phones) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ PHONE NUMBERS"))
+ sb.WriteString("\n")
+
+ for _, p := range phones {
+ phoneMap, ok := p.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ number := ""
+ if display, ok := phoneMap["display"].(string); ok && display != "" {
+ number = display
+ } else if num, ok := phoneMap["number"].(string); ok && num != "" {
+ number = num
+ }
+
+ if number == "" {
+ continue
+ }
+
+ sb.WriteString(fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportHighlightStyle.Render(number)))
+
+ var meta []string
+ if phoneType, ok := phoneMap["type"].(string); ok && phoneType != "" {
+ meta = append(meta, phoneType)
+ }
+ if carrier, ok := phoneMap["carrier"].(string); ok && carrier != "" {
+ meta = append(meta, carrier)
+ }
+ if lineType, ok := phoneMap["line_type"].(string); ok && lineType != "" {
+ meta = append(meta, lineType)
+ }
+ if len(meta) > 0 {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", strings.Join(meta, ", "))))
+ }
+ sb.WriteString("\n")
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportEmails(sb *strings.Builder, data map[string]interface{}) {
+ emails, ok := data["emails"].([]interface{})
+ if !ok || len(emails) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ EMAIL ADDRESSES"))
+ sb.WriteString("\n")
+
+ for _, e := range emails {
+ switch email := e.(type) {
+ case string:
+ if email != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("•"), reportHighlightStyle.Render(email)))
+ }
+ case map[string]interface{}:
+ addr := ""
+ if address, ok := email["address"].(string); ok && address != "" {
+ addr = address
+ } else if display, ok := email["display"].(string); ok && display != "" {
+ addr = display
+ }
+ if addr != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportHighlightStyle.Render(addr)))
+ if emailType, ok := email["type"].(string); ok && emailType != "" {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", emailType)))
+ }
+ sb.WriteString("\n")
+ }
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportURLs(sb *strings.Builder, data map[string]interface{}) {
+ urls, ok := data["urls"].([]interface{})
+ if !ok || len(urls) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ WEB PRESENCE"))
+ sb.WriteString("\n")
+
+ for _, u := range urls {
+ switch url := u.(type) {
+ case string:
+ if url != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("•"), reportHighlightStyle.Render(url)))
+ }
+ case map[string]interface{}:
+ urlStr := ""
+ if u, ok := url["url"].(string); ok && u != "" {
+ urlStr = u
+ } else if u, ok := url["@source_url"].(string); ok && u != "" {
+ urlStr = u
+ }
+ if urlStr != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportHighlightStyle.Render(urlStr)))
+ if domain, ok := url["@domain"].(string); ok && domain != "" {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", domain)))
+ } else if name, ok := url["@name"].(string); ok && name != "" {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", name)))
+ }
+ sb.WriteString("\n")
+ }
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportOtherProfiles(sb *strings.Builder, data map[string]interface{}) {
+ profiles, ok := data["otherProfiles"].([]interface{})
+ if !ok || len(profiles) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ OTHER PROFILES"))
+ sb.WriteString("\n")
+
+ for _, p := range profiles {
+ profileMap, ok := p.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ url := ""
+ if u, ok := profileMap["url"].(string); ok && u != "" {
+ url = u
+ } else if u, ok := profileMap["@source_url"].(string); ok && u != "" {
+ url = u
+ }
+
+ if url != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportHighlightStyle.Render(url)))
+ var meta []string
+ if name, ok := profileMap["@name"].(string); ok && name != "" {
+ meta = append(meta, name)
+ }
+ if domain, ok := profileMap["@domain"].(string); ok && domain != "" {
+ meta = append(meta, domain)
+ }
+ if category, ok := profileMap["@category"].(string); ok && category != "" {
+ meta = append(meta, category)
+ }
+ if len(meta) > 0 {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", strings.Join(meta, ", "))))
+ }
+ sb.WriteString("\n")
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportAssociates(sb *strings.Builder, data map[string]interface{}) {
+ associates, ok := data["associates"].([]interface{})
+ if !ok || len(associates) == 0 {
+ return
+ }
+
+ var lines []string
+ for _, a := range associates {
+ assocMap, ok := a.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ name := extractPersonName(assocMap)
+ if name == "" {
+ continue
+ }
+
+ line := fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportValueStyle.Render(name))
+ if relation, ok := assocMap["relation"].(string); ok && relation != "" {
+ line += reportDimStyle.Render(fmt.Sprintf(" (%s)", relation))
+ } else if relation, ok := assocMap["type"].(string); ok && relation != "" {
+ line += reportDimStyle.Render(fmt.Sprintf(" (%s)", relation))
+ }
+ lines = append(lines, line)
+ }
+
+ if len(lines) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ ASSOCIATES"))
+ sb.WriteString("\n")
+
+ for _, line := range lines {
+ sb.WriteString(line + "\n")
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportRelatives(sb *strings.Builder, data map[string]interface{}) {
+ relatives, ok := data["relatives"].([]interface{})
+ if !ok || len(relatives) == 0 {
+ relatives, ok = data["relationships"].([]interface{})
+ if !ok || len(relatives) == 0 {
+ return
+ }
+ }
+
+ var lines []string
+ for _, r := range relatives {
+ relMap, ok := r.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ name := extractPersonName(relMap)
+ if name == "" {
+ continue
+ }
+
+ line := fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportValueStyle.Render(name))
+ if relation, ok := relMap["type"].(string); ok && relation != "" {
+ line += reportDimStyle.Render(fmt.Sprintf(" (%s)", relation))
+ } else if relation, ok := relMap["relation"].(string); ok && relation != "" {
+ line += reportDimStyle.Render(fmt.Sprintf(" (%s)", relation))
+ }
+ lines = append(lines, line)
+ }
+
+ if len(lines) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ RELATIVES"))
+ sb.WriteString("\n")
+
+ for _, line := range lines {
+ sb.WriteString(line + "\n")
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportRelatedPersons(sb *strings.Builder, data map[string]interface{}) {
+ relatedPersons, ok := data["related_persons"].([]interface{})
+ if !ok || len(relatedPersons) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ RELATED PERSONS"))
+ sb.WriteString("\n")
+
+ for _, rp := range relatedPersons {
+ rpMap, ok := rp.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ name := extractPersonName(rpMap)
+ if name == "" {
+ continue
+ }
+
+ sb.WriteString(fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportValueStyle.Render(name)))
+
+ var meta []string
+ if relation, ok := rpMap["@type"].(string); ok && relation != "" {
+ meta = append(meta, relation)
+ } else if relation, ok := rpMap["type"].(string); ok && relation != "" {
+ meta = append(meta, relation)
+ }
+ if subtype, ok := rpMap["@subtype"].(string); ok && subtype != "" {
+ meta = append(meta, subtype)
+ }
+ if len(meta) > 0 {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" (%s)", strings.Join(meta, " - "))))
+ }
+ sb.WriteString("\n")
+
+ if phones, ok := rpMap["phones"].([]interface{}); ok && len(phones) > 0 {
+ for _, p := range phones {
+ if phoneMap, ok := p.(map[string]interface{}); ok {
+ if display, ok := phoneMap["display"].(string); ok && display != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("Phone:"), reportHighlightStyle.Render(display)))
+ }
+ }
+ }
+ }
+
+ if emails, ok := rpMap["emails"].([]interface{}); ok && len(emails) > 0 {
+ for _, e := range emails {
+ if emailMap, ok := e.(map[string]interface{}); ok {
+ if addr, ok := emailMap["address"].(string); ok && addr != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("Email:"), reportHighlightStyle.Render(addr)))
+ }
+ }
+ }
+ }
+
+ if addresses, ok := rpMap["addresses"].([]interface{}); ok && len(addresses) > 0 {
+ if addrMap, ok := addresses[0].(map[string]interface{}); ok {
+ if display, ok := addrMap["display"].(string); ok && display != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("Address:"), reportValueStyle.Render(display)))
+ }
+ }
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportPossibleAssociates(sb *strings.Builder, data map[string]interface{}) {
+ associates, ok := data["possible_associates"].([]interface{})
+ if !ok || len(associates) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ POSSIBLE ASSOCIATES"))
+ sb.WriteString("\n")
+
+ for i, a := range associates {
+ if i >= 10 {
+ sb.WriteString(fmt.Sprintf(" %s\n", reportDimStyle.Render(fmt.Sprintf("... and %d more", len(associates)-10))))
+ break
+ }
+
+ assocMap, ok := a.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ name := extractPersonName(assocMap)
+ if name == "" {
+ continue
+ }
+
+ sb.WriteString(fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportValueStyle.Render(name)))
+
+ var meta []string
+ if relation, ok := assocMap["@type"].(string); ok && relation != "" {
+ meta = append(meta, relation)
+ }
+ if len(meta) > 0 {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" (%s)", strings.Join(meta, ", "))))
+ }
+ sb.WriteString("\n")
+
+ if addresses, ok := assocMap["addresses"].([]interface{}); ok && len(addresses) > 0 {
+ if addrMap, ok := addresses[0].(map[string]interface{}); ok {
+ if display, ok := addrMap["display"].(string); ok && display != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("Location:"), reportValueStyle.Render(display)))
+ }
+ }
+ }
+
+ if phones, ok := assocMap["phones"].([]interface{}); ok && len(phones) > 0 {
+ if phoneMap, ok := phones[0].(map[string]interface{}); ok {
+ if display, ok := phoneMap["display"].(string); ok && display != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("Phone:"), reportHighlightStyle.Render(display)))
+ }
+ }
+ }
+
+ if emails, ok := assocMap["emails"].([]interface{}); ok && len(emails) > 0 {
+ if emailMap, ok := emails[0].(map[string]interface{}); ok {
+ if addr, ok := emailMap["address"].(string); ok && addr != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("Email:"), reportHighlightStyle.Render(addr)))
+ }
+ }
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func extractPersonName(personMap map[string]interface{}) string {
+ if display, ok := personMap["name"].(string); ok && display != "" {
+ return display
+ }
+
+ if display, ok := personMap["display"].(string); ok && display != "" {
+ return display
+ }
+
+ if names, ok := personMap["names"].([]interface{}); ok && len(names) > 0 {
+ if nameMap, ok := names[0].(map[string]interface{}); ok {
+ if display, ok := nameMap["display"].(string); ok && display != "" {
+ return display
+ }
+ var parts []string
+ if first, ok := nameMap["first"].(string); ok && first != "" {
+ parts = append(parts, first)
+ }
+ if middle, ok := nameMap["middle"].(string); ok && middle != "" {
+ parts = append(parts, middle)
+ }
+ if last, ok := nameMap["last"].(string); ok && last != "" {
+ parts = append(parts, last)
+ }
+ if len(parts) > 0 {
+ return strings.Join(parts, " ")
+ }
+ }
+ }
+
+ var parts []string
+ if first, ok := personMap["first_name"].(string); ok && first != "" {
+ parts = append(parts, first)
+ }
+ if last, ok := personMap["last_name"].(string); ok && last != "" {
+ parts = append(parts, last)
+ }
+ if len(parts) > 0 {
+ return strings.Join(parts, " ")
+ }
+
+ return ""
+}
+
+func formatReportEmployment(sb *strings.Builder, data map[string]interface{}) {
+ jobs, ok := data["jobs"].([]interface{})
+ if !ok || len(jobs) == 0 {
+ jobs, ok = data["employment"].([]interface{})
+ if !ok || len(jobs) == 0 {
+ return
+ }
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ EMPLOYMENT"))
+ sb.WriteString("\n")
+
+ for _, j := range jobs {
+ jobMap, ok := j.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ var parts []string
+ if title, ok := jobMap["title"].(string); ok && title != "" {
+ parts = append(parts, title)
+ }
+ if org, ok := jobMap["organization"].(string); ok && org != "" {
+ parts = append(parts, fmt.Sprintf("at %s", org))
+ } else if company, ok := jobMap["company"].(string); ok && company != "" {
+ parts = append(parts, fmt.Sprintf("at %s", company))
+ }
+
+ if len(parts) > 0 {
+ sb.WriteString(fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportValueStyle.Render(strings.Join(parts, " "))))
+ if dateRange, ok := jobMap["date_range"].(string); ok && dateRange != "" {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", dateRange)))
+ }
+ sb.WriteString("\n")
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportEducation(sb *strings.Builder, data map[string]interface{}) {
+ education, ok := data["educations"].([]interface{})
+ if !ok || len(education) == 0 {
+ education, ok = data["education"].([]interface{})
+ if !ok || len(education) == 0 {
+ return
+ }
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ EDUCATION"))
+ sb.WriteString("\n")
+
+ for _, e := range education {
+ eduMap, ok := e.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ var parts []string
+ if degree, ok := eduMap["degree"].(string); ok && degree != "" {
+ parts = append(parts, degree)
+ }
+ if school, ok := eduMap["school"].(string); ok && school != "" {
+ parts = append(parts, fmt.Sprintf("from %s", school))
+ }
+
+ if len(parts) > 0 {
+ sb.WriteString(fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportValueStyle.Render(strings.Join(parts, " "))))
+ if dateRange, ok := eduMap["date_range"].(string); ok && dateRange != "" {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", dateRange)))
+ }
+ sb.WriteString("\n")
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportProperties(sb *strings.Builder, data map[string]interface{}) {
+ current, hasCurrentProps := data["current_properties"].([]interface{})
+ past, hasPastProps := data["past_properties"].([]interface{})
+ all, hasAllProps := data["properties"].([]interface{})
+
+ hasValidCurrent := hasCurrentProps && len(current) > 0 && hasValidProperty(current)
+ hasValidPast := hasPastProps && len(past) > 0 && hasValidProperty(past)
+ hasValidAll := hasAllProps && len(all) > 0 && hasValidProperty(all)
+
+ if !hasValidCurrent && !hasValidPast && !hasValidAll {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ PROPERTIES"))
+ sb.WriteString("\n")
+
+ if hasValidCurrent {
+ sb.WriteString(reportSubsectionStyle.Render(" Current Properties:"))
+ sb.WriteString("\n")
+ for _, p := range current {
+ _ = formatReportProperty(sb, p, " ")
+ }
+ }
+
+ if hasValidPast {
+ sb.WriteString(reportSubsectionStyle.Render(" Past Properties:"))
+ sb.WriteString("\n")
+ count := 0
+ for _, p := range past {
+ if count >= 3 {
+ sb.WriteString(fmt.Sprintf(" %s\n", reportDimStyle.Render(fmt.Sprintf("... and %d more", len(past)-3))))
+ break
+ }
+ if formatReportProperty(sb, p, " ") {
+ count++
+ }
+ }
+ }
+
+ if hasValidAll && !hasValidCurrent && !hasValidPast {
+ count := 0
+ for _, p := range all {
+ if count >= 5 {
+ sb.WriteString(fmt.Sprintf(" %s\n", reportDimStyle.Render(fmt.Sprintf("... and %d more", len(all)-5))))
+ break
+ }
+ if formatReportProperty(sb, p, " ") {
+ count++
+ }
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func hasValidProperty(props []interface{}) bool {
+ for _, p := range props {
+ propMap, ok := p.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ if extractPropertyAddress(propMap) != "" {
+ return true
+ }
+ }
+ return false
+}
+
+func extractPropertyAddress(propMap map[string]interface{}) string {
+ if addr, ok := propMap["address"].(string); ok && addr != "" {
+ return addr
+ }
+
+ if addrMap, ok := propMap["address"].(map[string]interface{}); ok {
+ if display, ok := addrMap["display"].(string); ok && display != "" {
+ return display
+ }
+ return buildAddressFromParts(addrMap)
+ }
+
+ if display, ok := propMap["display"].(string); ok && display != "" {
+ return display
+ }
+
+ return buildAddressFromParts(propMap)
+}
+
+func buildAddressFromParts(addrMap map[string]interface{}) string {
+ var parts []string
+
+ if street, ok := addrMap["street"].(string); ok && street != "" {
+ parts = append(parts, street)
+ } else if street, ok := addrMap["street_address"].(string); ok && street != "" {
+ parts = append(parts, street)
+ } else if line1, ok := addrMap["line1"].(string); ok && line1 != "" {
+ parts = append(parts, line1)
+ }
+
+ if city, ok := addrMap["city"].(string); ok && city != "" {
+ parts = append(parts, city)
+ }
+ if state, ok := addrMap["state"].(string); ok && state != "" {
+ parts = append(parts, state)
+ }
+ if zip, ok := addrMap["zip"].(string); ok && zip != "" {
+ parts = append(parts, zip)
+ } else if zip, ok := addrMap["zip_code"].(string); ok && zip != "" {
+ parts = append(parts, zip)
+ } else if postal, ok := addrMap["postal_code"].(string); ok && postal != "" {
+ parts = append(parts, postal)
+ }
+
+ if len(parts) > 0 {
+ return strings.Join(parts, ", ")
+ }
+ return ""
+}
+
+func formatReportProperty(sb *strings.Builder, prop interface{}, indent string) bool {
+ propMap, ok := prop.(map[string]interface{})
+ if !ok {
+ return false
+ }
+
+ address := extractPropertyAddress(propMap)
+ if address == "" {
+ return false
+ }
+
+ sb.WriteString(fmt.Sprintf("%s%s %s", indent, reportDimStyle.Render("•"), reportValueStyle.Render(address)))
+
+ var meta []string
+ if propType, ok := propMap["type"].(string); ok && propType != "" {
+ meta = append(meta, propType)
+ }
+ if value, ok := propMap["value"].(float64); ok && value > 0 {
+ meta = append(meta, fmt.Sprintf("$%.0f", value))
+ } else if value, ok := propMap["assessed_value"].(float64); ok && value > 0 {
+ meta = append(meta, fmt.Sprintf("assessed: $%.0f", value))
+ }
+ if len(meta) > 0 {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", strings.Join(meta, ", "))))
+ }
+ sb.WriteString("\n")
+
+ detailIndent := indent + " "
+
+ if ownership, ok := propMap["ownership"].(map[string]interface{}); ok {
+ if ownerName, ok := ownership["owner_name"].(string); ok && ownerName != "" {
+ sb.WriteString(fmt.Sprintf("%s%s %s\n", detailIndent, reportDimStyle.Render("Owner:"), reportValueStyle.Render(ownerName)))
+ }
+ if ownerType, ok := ownership["owner_type"].(string); ok && ownerType != "" {
+ sb.WriteString(fmt.Sprintf("%s%s %s\n", detailIndent, reportDimStyle.Render("Type:"), reportValueStyle.Render(ownerType)))
+ }
+ }
+
+ if mortgage, ok := propMap["mortgage"].(map[string]interface{}); ok {
+ formatPropertyMortgage(sb, mortgage, detailIndent)
+ }
+ if mortgages, ok := propMap["mortgages"].([]interface{}); ok && len(mortgages) > 0 {
+ if mortgageMap, ok := mortgages[0].(map[string]interface{}); ok {
+ formatPropertyMortgage(sb, mortgageMap, detailIndent)
+ }
+ }
+
+ if tax, ok := propMap["tax_assessment"].(map[string]interface{}); ok {
+ formatPropertyTax(sb, tax, detailIndent)
+ }
+ if tax, ok := propMap["taxAssessment"].(map[string]interface{}); ok {
+ formatPropertyTax(sb, tax, detailIndent)
+ }
+
+ return true
+}
+
+func formatPropertyMortgage(sb *strings.Builder, mortgage map[string]interface{}, indent string) {
+ var mortgageInfo []string
+
+ if lender, ok := mortgage["lender"].(string); ok && lender != "" {
+ mortgageInfo = append(mortgageInfo, lender)
+ }
+ if amount, ok := mortgage["amount"].(float64); ok && amount > 0 {
+ mortgageInfo = append(mortgageInfo, fmt.Sprintf("$%.0f", amount))
+ } else if amount, ok := mortgage["loan_amount"].(float64); ok && amount > 0 {
+ mortgageInfo = append(mortgageInfo, fmt.Sprintf("$%.0f", amount))
+ }
+ if date, ok := mortgage["date"].(string); ok && date != "" {
+ mortgageInfo = append(mortgageInfo, date)
+ } else if date, ok := mortgage["recording_date"].(string); ok && date != "" {
+ mortgageInfo = append(mortgageInfo, date)
+ }
+
+ if len(mortgageInfo) > 0 {
+ sb.WriteString(fmt.Sprintf("%s%s %s\n", indent, reportDimStyle.Render("Mortgage:"), reportValueStyle.Render(strings.Join(mortgageInfo, " | "))))
+ }
+}
+
+func formatPropertyTax(sb *strings.Builder, tax map[string]interface{}, indent string) {
+ var taxInfo []string
+
+ if assessed, ok := tax["assessed_value"].(float64); ok && assessed > 0 {
+ taxInfo = append(taxInfo, fmt.Sprintf("assessed: $%.0f", assessed))
+ }
+ if market, ok := tax["market_value"].(float64); ok && market > 0 {
+ taxInfo = append(taxInfo, fmt.Sprintf("market: $%.0f", market))
+ }
+ if taxAmount, ok := tax["tax_amount"].(float64); ok && taxAmount > 0 {
+ taxInfo = append(taxInfo, fmt.Sprintf("tax: $%.0f", taxAmount))
+ }
+ if year, ok := tax["year"].(float64); ok && year > 0 {
+ taxInfo = append(taxInfo, fmt.Sprintf("year: %d", int(year)))
+ } else if year, ok := tax["tax_year"].(string); ok && year != "" {
+ taxInfo = append(taxInfo, fmt.Sprintf("year: %s", year))
+ }
+
+ if len(taxInfo) > 0 {
+ sb.WriteString(fmt.Sprintf("%s%s %s\n", indent, reportDimStyle.Render("Tax:"), reportValueStyle.Render(strings.Join(taxInfo, " | "))))
+ }
+}
+
+func formatReportVehicles(sb *strings.Builder, data map[string]interface{}) {
+ vehicles, ok := data["vehicles"].([]interface{})
+ if !ok || len(vehicles) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ VEHICLES"))
+ sb.WriteString("\n")
+
+ for _, v := range vehicles {
+ vehMap, ok := v.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ var parts []string
+ if year, ok := vehMap["year"].(string); ok && year != "" {
+ parts = append(parts, year)
+ } else if year, ok := vehMap["year"].(float64); ok && year > 0 {
+ parts = append(parts, fmt.Sprintf("%d", int(year)))
+ }
+ if make, ok := vehMap["make"].(string); ok && make != "" {
+ parts = append(parts, make)
+ }
+ if model, ok := vehMap["model"].(string); ok && model != "" {
+ parts = append(parts, model)
+ }
+
+ if len(parts) > 0 {
+ sb.WriteString(fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportValueStyle.Render(strings.Join(parts, " "))))
+ if vin, ok := vehMap["vin"].(string); ok && vin != "" {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [VIN: %s]", vin)))
+ }
+ sb.WriteString("\n")
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportCriminalRecords(sb *strings.Builder, data map[string]interface{}) {
+ records, ok := data["criminal_records"].([]interface{})
+ if !ok || len(records) == 0 {
+ if crimRecords, ok := data["criminalRecords"].([]interface{}); ok && len(crimRecords) > 0 {
+ records = crimRecords
+ } else {
+ return
+ }
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ CRIMINAL RECORDS"))
+ sb.WriteString("\n")
+
+ for _, r := range records {
+ recMap, ok := r.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ formatCriminalRecord(sb, recMap)
+
+ if enhanced, ok := recMap["enhanced_record"].(map[string]interface{}); ok {
+ formatEnhancedCriminalRecord(sb, enhanced)
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func formatCriminalRecord(sb *strings.Builder, recMap map[string]interface{}) {
+ offense := ""
+ if o, ok := recMap["offense"].(string); ok && o != "" {
+ offense = o
+ } else if o, ok := recMap["charge"].(string); ok && o != "" {
+ offense = o
+ }
+
+ if offense == "" {
+ return
+ }
+
+ sb.WriteString(fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportWarningStyle.Render(offense)))
+
+ var meta []string
+ if caseType, ok := recMap["case_type"].(string); ok && caseType != "" {
+ meta = append(meta, caseType)
+ }
+ if date, ok := recMap["date"].(string); ok && date != "" {
+ meta = append(meta, date)
+ } else if date, ok := recMap["offense_date"].(string); ok && date != "" {
+ meta = append(meta, date)
+ }
+ if county, ok := recMap["county"].(string); ok && county != "" {
+ meta = append(meta, county)
+ }
+ if state, ok := recMap["state"].(string); ok && state != "" {
+ meta = append(meta, state)
+ }
+ if disposition, ok := recMap["disposition"].(string); ok && disposition != "" {
+ meta = append(meta, disposition)
+ }
+ if len(meta) > 0 {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", strings.Join(meta, ", "))))
+ }
+ sb.WriteString("\n")
+
+ if crimeDetails, ok := recMap["crime_details"].(map[string]interface{}); ok {
+ if severity, ok := crimeDetails["severity"].(string); ok && severity != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("Severity:"), reportWarningStyle.Render(severity)))
+ }
+ if category, ok := crimeDetails["category"].(string); ok && category != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("Category:"), reportValueStyle.Render(category)))
+ }
+ }
+
+ if matchIndicators, ok := recMap["match_indicators"].(map[string]interface{}); ok {
+ var indicators []string
+ if name, ok := matchIndicators["name_match"].(bool); ok && name {
+ indicators = append(indicators, "name")
+ }
+ if dob, ok := matchIndicators["dob_match"].(bool); ok && dob {
+ indicators = append(indicators, "DOB")
+ }
+ if addr, ok := matchIndicators["address_match"].(bool); ok && addr {
+ indicators = append(indicators, "address")
+ }
+ if len(indicators) > 0 {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("Matched on:"), reportValueStyle.Render(strings.Join(indicators, ", "))))
+ }
+ }
+}
+
+func formatEnhancedCriminalRecord(sb *strings.Builder, enhanced map[string]interface{}) {
+ if caseNumber, ok := enhanced["case_number"].(string); ok && caseNumber != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("Case #:"), reportValueStyle.Render(caseNumber)))
+ }
+ if court, ok := enhanced["court"].(string); ok && court != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("Court:"), reportValueStyle.Render(court)))
+ }
+ if sentence, ok := enhanced["sentence"].(string); ok && sentence != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("Sentence:"), reportValueStyle.Render(sentence)))
+ }
+ if filingDate, ok := enhanced["filing_date"].(string); ok && filingDate != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("Filed:"), reportValueStyle.Render(filingDate)))
+ }
+}
+
+func formatReportVoterRecords(sb *strings.Builder, data map[string]interface{}) {
+ voter, ok := data["voter_record"].(map[string]interface{})
+ if !ok {
+ if records, ok := data["voter_records"].([]interface{}); ok && len(records) > 0 {
+ if v, ok := records[0].(map[string]interface{}); ok {
+ voter = v
+ }
+ }
+ }
+ if voter == nil {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ VOTER REGISTRATION"))
+ sb.WriteString("\n")
+
+ if party, ok := voter["party"].(string); ok && party != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportLabelStyle.Render("Party:"), reportValueStyle.Render(party)))
+ }
+ if status, ok := voter["status"].(string); ok && status != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportLabelStyle.Render("Status:"), reportValueStyle.Render(status)))
+ }
+ if regDate, ok := voter["registration_date"].(string); ok && regDate != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportLabelStyle.Render("Registered:"), reportValueStyle.Render(regDate)))
+ }
+ if county, ok := voter["county"].(string); ok && county != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportLabelStyle.Render("County:"), reportValueStyle.Render(county)))
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportSocialProfiles(sb *strings.Builder, data map[string]interface{}) {
+ profiles, ok := data["social_profiles"].([]interface{})
+ if !ok || len(profiles) == 0 {
+ profiles, ok = data["urls"].([]interface{})
+ if !ok || len(profiles) == 0 {
+ return
+ }
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ SOCIAL PROFILES"))
+ sb.WriteString("\n")
+
+ for _, p := range profiles {
+ switch profile := p.(type) {
+ case string:
+ if profile != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("•"), reportHighlightStyle.Render(profile)))
+ }
+ case map[string]interface{}:
+ url := ""
+ if u, ok := profile["url"].(string); ok && u != "" {
+ url = u
+ } else if u, ok := profile["display"].(string); ok && u != "" {
+ url = u
+ }
+ if url != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportHighlightStyle.Render(url)))
+ if network, ok := profile["network"].(string); ok && network != "" {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", network)))
+ } else if domain, ok := profile["domain"].(string); ok && domain != "" {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", domain)))
+ }
+ sb.WriteString("\n")
+ }
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportImages(sb *strings.Builder, data map[string]interface{}) {
+ images, ok := data["images"].([]interface{})
+ if !ok || len(images) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ IMAGES"))
+ sb.WriteString("\n")
+
+ for _, img := range images {
+ switch image := img.(type) {
+ case string:
+ if image != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("•"), reportDimStyle.Render(image)))
+ }
+ case map[string]interface{}:
+ if url, ok := image["url"].(string); ok && url != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("•"), reportDimStyle.Render(url)))
+ }
+ }
+ }
+ sb.WriteString("\n")
+}
+
+func formatReportTags(sb *strings.Builder, data map[string]interface{}) {
+ tags, ok := data["tags"].([]interface{})
+ if !ok || len(tags) == 0 {
+ return
+ }
+
+ sb.WriteString(reportSectionStyle.Render("▸ TAGS / DATA BREACH INFO"))
+ sb.WriteString("\n")
+
+ for _, t := range tags {
+ switch tag := t.(type) {
+ case string:
+ if tag != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", reportDimStyle.Render("•"), reportWarningStyle.Render(tag)))
+ }
+ case map[string]interface{}:
+ content := ""
+ if c, ok := tag["content"].(string); ok && c != "" {
+ content = c
+ } else if c, ok := tag["name"].(string); ok && c != "" {
+ content = c
+ }
+ if content != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s", reportDimStyle.Render("•"), reportWarningStyle.Render(content)))
+ if source, ok := tag["source"].(string); ok && source != "" {
+ sb.WriteString(reportDimStyle.Render(fmt.Sprintf(" [%s]", source)))
+ }
+ sb.WriteString("\n")
+ }
+ }
+ }
+ sb.WriteString("\n")
+}
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
+}