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 | |
| 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
| -rw-r--r-- | cmd/services.go | 2 | ||||
| -rw-r--r-- | cmd/skiptrace.go | 111 | ||||
| -rw-r--r-- | go.mod | 25 | ||||
| -rw-r--r-- | go.sum | 51 | ||||
| -rw-r--r-- | internal/formatter/skiptrace.go | 1289 | ||||
| -rw-r--r-- | internal/tui/person_selector.go | 426 | ||||
| -rw-r--r-- | internal/tui/wizard.go | 478 |
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) +} @@ -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 ) @@ -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 +} |
