summaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authors <[email protected]>2025-11-30 00:34:34 -0500
committers <[email protected]>2025-11-30 00:34:34 -0500
commit45f72539c2a6638fd0e777fcd1e788a7084ff8b2 (patch)
treed51191b9ce1007423ab5d9b778143025b25a44f6 /internal
parent7d6eb2f1a38e2265751cd61a43769959405866b4 (diff)
downloaddborg-1.0.7.tar.gz
dborg-1.0.7.zip
feat: add interactive tui wizard for skiptrace person search and report generationv1.0.7
Diffstat (limited to 'internal')
-rw-r--r--internal/formatter/skiptrace.go1289
-rw-r--r--internal/tui/person_selector.go426
-rw-r--r--internal/tui/wizard.go478
3 files changed, 2193 insertions, 0 deletions
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
+}