summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authors <[email protected]>2025-11-13 14:43:15 -0500
committers <[email protected]>2025-11-13 14:43:15 -0500
commit344a6f6415c3c1b593677adec3b8844e0839971b (patch)
treeb05291ecdf21917b27e9e234eeb997c2706966d5
parenta5fc01a03753c9a18ddeaf13610dd99b4b311b80 (diff)
downloaddborg-344a6f6415c3c1b593677adec3b8844e0839971b.tar.gz
dborg-344a6f6415c3c1b593677adec3b8844e0839971b.zip
created pretty printing for all commandsv1.0.0
-rw-r--r--cmd/admin.go58
-rw-r--r--cmd/dns.go12
-rw-r--r--cmd/npd.go11
-rw-r--r--cmd/osint.go29
-rw-r--r--cmd/reddit.go37
-rw-r--r--cmd/root.go9
-rw-r--r--cmd/skiptrace.go48
-rw-r--r--cmd/sl.go11
-rw-r--r--cmd/x.go67
-rw-r--r--internal/formatter/admin.go187
-rw-r--r--internal/formatter/breachforum.go186
-rw-r--r--internal/formatter/bssid.go47
-rw-r--r--internal/formatter/buckets.go470
-rw-r--r--internal/formatter/buckets_test.go509
-rw-r--r--internal/formatter/dns.go57
-rw-r--r--internal/formatter/files.go79
-rw-r--r--internal/formatter/formatter.go478
-rw-r--r--internal/formatter/geo.go10
-rw-r--r--internal/formatter/npd.go139
-rw-r--r--internal/formatter/reddit.go444
-rw-r--r--internal/formatter/shortlinks.go80
-rw-r--r--internal/formatter/skiptrace.go128
-rw-r--r--internal/formatter/sl.go125
-rw-r--r--internal/formatter/username.go143
-rw-r--r--internal/formatter/x.go379
-rw-r--r--internal/utils/output.go6
26 files changed, 3660 insertions, 89 deletions
diff --git a/cmd/admin.go b/cmd/admin.go
index 82e2c29..3c20068 100644
--- a/cmd/admin.go
+++ b/cmd/admin.go
@@ -4,8 +4,8 @@ import (
"fmt"
"git.db.org.ai/dborg/internal/client"
"git.db.org.ai/dborg/internal/config"
+ "git.db.org.ai/dborg/internal/formatter"
"git.db.org.ai/dborg/internal/models"
- "git.db.org.ai/dborg/internal/utils"
"strconv"
"github.com/spf13/cobra"
@@ -93,7 +93,13 @@ func runAdminList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- return utils.PrintJSON(response.Accounts)
+ output, err := formatter.FormatAccountList(response.Accounts, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+
+ fmt.Print(output)
+ return nil
}
func runAdminCreate(cmd *cobra.Command, args []string) error {
@@ -122,17 +128,12 @@ func runAdminCreate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- if response.Account != nil {
- fmt.Printf("Account created successfully!\n")
- fmt.Printf("Name: %s\n", response.Account.Name)
- fmt.Printf("API Key: %s\n", response.Account.APIKey)
- fmt.Printf("Credits: %d\n", response.Account.Credits)
- fmt.Printf("Unlimited: %v\n", response.Account.Unlimited)
- fmt.Printf("Premium: %v\n", response.Account.IsPremium)
- fmt.Printf("Disabled: %v\n", response.Account.Disabled)
- } else {
- fmt.Println(response.Message)
+ output, err := formatter.FormatAccountCreated(response.Account, response.Message, IsJSONOutput())
+ if err != nil {
+ return err
}
+
+ fmt.Print(output)
return nil
}
@@ -151,7 +152,12 @@ func runAdminDelete(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- fmt.Println(response.Message)
+ output, err := formatter.FormatAccountDeleted(response.Message, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+
+ fmt.Print(output)
return nil
}
@@ -175,7 +181,12 @@ func runAdminCredits(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- fmt.Println(response.Message)
+ output, err := formatter.FormatCreditsUpdated(response.Message, nil, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+
+ fmt.Print(output)
return nil
}
@@ -199,14 +210,12 @@ func runAdminSetCredits(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- if response.Account != nil {
- fmt.Printf("Credits set successfully!\n")
- fmt.Printf("Account: %s\n", response.Account.Name)
- fmt.Printf("API Key: %s\n", response.Account.APIKey)
- fmt.Printf("Credits: %d\n", response.Account.Credits)
- } else if response.Message != "" {
- fmt.Println(response.Message)
+ output, err := formatter.FormatCreditsUpdated(response.Message, response.Account, IsJSONOutput())
+ if err != nil {
+ return err
}
+
+ fmt.Print(output)
return nil
}
@@ -226,6 +235,11 @@ func runAdminDisable(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- fmt.Println(response.Message)
+ output, err := formatter.FormatAccountToggled(response.Message, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+
+ fmt.Print(output)
return nil
}
diff --git a/cmd/dns.go b/cmd/dns.go
index d6776ee..130f394 100644
--- a/cmd/dns.go
+++ b/cmd/dns.go
@@ -6,6 +6,7 @@ import (
"git.db.org.ai/dborg/internal/client"
"git.db.org.ai/dborg/internal/config"
+ "git.db.org.ai/dborg/internal/formatter"
"git.db.org.ai/dborg/internal/models"
"github.com/spf13/cobra"
)
@@ -42,7 +43,16 @@ func runDNSTLDCheck(cmd *cobra.Command, args []string) error {
fmt.Printf("Checking TLDs for term: %s\n\n", term)
err = c.CheckDNSTLDStream(params, func(result json.RawMessage) error {
- fmt.Println(string(result))
+ var domainResult models.DomainResult
+ if err := json.Unmarshal(result, &domainResult); err != nil {
+ return fmt.Errorf("failed to parse result: %w", err)
+ }
+
+ output, err := formatter.FormatDNSResults(&domainResult, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+ fmt.Print(output)
return nil
})
diff --git a/cmd/npd.go b/cmd/npd.go
index 9868eae..c8b0b28 100644
--- a/cmd/npd.go
+++ b/cmd/npd.go
@@ -2,11 +2,11 @@ package cmd
import (
"fmt"
+
"git.db.org.ai/dborg/internal/client"
"git.db.org.ai/dborg/internal/config"
+ "git.db.org.ai/dborg/internal/formatter"
"git.db.org.ai/dborg/internal/models"
- "git.db.org.ai/dborg/internal/utils"
-
"github.com/spf13/cobra"
)
@@ -84,5 +84,10 @@ func runNPDSearch(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- return utils.PrintJSON(response.Results.Hits)
+ output, err := formatter.FormatNPDResults(response, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+ fmt.Print(output)
+ return nil
}
diff --git a/cmd/osint.go b/cmd/osint.go
index 3c3d0de..e29ef54 100644
--- a/cmd/osint.go
+++ b/cmd/osint.go
@@ -5,8 +5,8 @@ import (
"fmt"
"git.db.org.ai/dborg/internal/client"
"git.db.org.ai/dborg/internal/config"
+ "git.db.org.ai/dborg/internal/formatter"
"git.db.org.ai/dborg/internal/models"
- "git.db.org.ai/dborg/internal/utils"
"github.com/spf13/cobra"
)
@@ -149,8 +149,17 @@ func runUsernameCheck(cmd *cobra.Command, args []string) error {
params.MaxTasks, _ = cmd.Flags().GetInt("max_tasks")
err = c.CheckUsernameStream(params, func(result json.RawMessage) error {
- fmt.Println(string(result))
- return nil
+ if IsJSONOutput() {
+ fmt.Println(string(result))
+ return nil
+ }
+
+ var siteResult models.SiteResult
+ if err := json.Unmarshal(result, &siteResult); err != nil {
+ return err
+ }
+
+ return formatter.FormatUsernameSiteResult(&siteResult)
})
if err != nil {
@@ -180,7 +189,7 @@ func runBSSIDLookup(cmd *cobra.Command, args []string) error {
return err
}
- return utils.PrintJSON(response)
+ return formatter.FormatBSSIDResults(*response, IsJSONOutput())
}
func runBreachForumSearch(cmd *cobra.Command, args []string) error {
@@ -201,7 +210,7 @@ func runBreachForumSearch(cmd *cobra.Command, args []string) error {
return err
}
- return utils.PrintJSON(response)
+ return formatter.FormatBreachForumResults(response, IsJSONOutput())
}
func runFilesSearch(cmd *cobra.Command, args []string) error {
@@ -226,7 +235,7 @@ func runFilesSearch(cmd *cobra.Command, args []string) error {
return err
}
- return utils.PrintJSON(response)
+ return formatter.FormatFilesResults(*response, IsJSONOutput())
}
func runBucketsSearch(cmd *cobra.Command, args []string) error {
@@ -246,7 +255,7 @@ func runBucketsSearch(cmd *cobra.Command, args []string) error {
return err
}
- return utils.PrintJSON(response)
+ return formatter.FormatBucketsResults(response, IsJSONOutput())
}
func runBucketFilesSearch(cmd *cobra.Command, args []string) error {
@@ -269,7 +278,7 @@ func runBucketFilesSearch(cmd *cobra.Command, args []string) error {
return err
}
- return utils.PrintJSON(response)
+ return formatter.FormatBucketFilesResults(response, IsJSONOutput())
}
func runShortlinksSearch(cmd *cobra.Command, args []string) error {
@@ -294,7 +303,7 @@ func runShortlinksSearch(cmd *cobra.Command, args []string) error {
return err
}
- return utils.PrintJSON(response)
+ return formatter.FormatShortlinksResults(response, IsJSONOutput())
}
func runGeoSearch(cmd *cobra.Command, args []string) error {
@@ -316,7 +325,7 @@ func runGeoSearch(cmd *cobra.Command, args []string) error {
return err
}
- return utils.PrintJSON(response)
+ return formatter.FormatGeoResults(*response, IsJSONOutput())
}
func runCrawl(cmd *cobra.Command, args []string) error {
diff --git a/cmd/reddit.go b/cmd/reddit.go
index 194fdab..5969f7c 100644
--- a/cmd/reddit.go
+++ b/cmd/reddit.go
@@ -5,8 +5,8 @@ import (
"git.db.org.ai/dborg/internal/client"
"git.db.org.ai/dborg/internal/config"
+ "git.db.org.ai/dborg/internal/formatter"
"git.db.org.ai/dborg/internal/models"
- "git.db.org.ai/dborg/internal/utils"
"github.com/spf13/cobra"
)
@@ -102,7 +102,12 @@ func runRedditSubredditPosts(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- return utils.PrintJSON(response)
+ output, err := formatter.FormatRedditResults(response, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+ fmt.Print(output)
+ return nil
}
func runRedditSubredditComments(cmd *cobra.Command, args []string) error {
@@ -126,7 +131,12 @@ func runRedditSubredditComments(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- return utils.PrintJSON(response)
+ output, err := formatter.FormatRedditResults(response, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+ fmt.Print(output)
+ return nil
}
func runRedditUserPosts(cmd *cobra.Command, args []string) error {
@@ -150,7 +160,12 @@ func runRedditUserPosts(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- return utils.PrintJSON(response)
+ output, err := formatter.FormatRedditResults(response, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+ fmt.Print(output)
+ return nil
}
func runRedditUserComments(cmd *cobra.Command, args []string) error {
@@ -174,7 +189,12 @@ func runRedditUserComments(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- return utils.PrintJSON(response)
+ output, err := formatter.FormatRedditResults(response, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+ fmt.Print(output)
+ return nil
}
func runRedditUserAbout(cmd *cobra.Command, args []string) error {
@@ -198,5 +218,10 @@ func runRedditUserAbout(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- return utils.PrintJSON(response)
+ output, err := formatter.FormatRedditResults(response, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+ fmt.Print(output)
+ return nil
}
diff --git a/cmd/root.go b/cmd/root.go
index 890d336..c4239ba 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -8,6 +8,10 @@ import (
"github.com/spf13/cobra"
)
+var (
+ jsonOutput bool
+)
+
var rootCmd = &cobra.Command{
Use: "dborg",
Short: "DB.org.ai CLI client",
@@ -28,4 +32,9 @@ func Execute() {
}
func init() {
+ rootCmd.PersistentFlags().BoolVarP(&jsonOutput, "json", "j", false, "Output raw JSON instead of formatted text")
+}
+
+func IsJSONOutput() bool {
+ return jsonOutput
}
diff --git a/cmd/skiptrace.go b/cmd/skiptrace.go
index 9ce1b4b..307204c 100644
--- a/cmd/skiptrace.go
+++ b/cmd/skiptrace.go
@@ -4,8 +4,8 @@ import (
"fmt"
"git.db.org.ai/dborg/internal/client"
"git.db.org.ai/dborg/internal/config"
+ "git.db.org.ai/dborg/internal/formatter"
"git.db.org.ai/dborg/internal/models"
- "git.db.org.ai/dborg/internal/utils"
"strconv"
"github.com/spf13/cobra"
@@ -90,10 +90,16 @@ func runSkiptracePeople(cmd *cobra.Command, args []string) error {
return err
}
- if response.Data != nil && len(response.Data) > 0 {
- return utils.PrintJSON(response.Data)
+ if response.Error != "" {
+ return fmt.Errorf("API error: %s", response.Error)
}
+ output, err := formatter.FormatSkiptraceResults(response, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+
+ fmt.Print(output)
return nil
}
@@ -118,16 +124,12 @@ func runSkiptraceReport(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- if response.Data != nil && len(response.Data) > 0 {
- if err := utils.PrintJSON(response.Data); err != nil {
- return err
- }
- }
-
- if response.Message != "" {
- fmt.Println(response.Message)
+ output, err := formatter.FormatSkiptraceResults(response, IsJSONOutput())
+ if err != nil {
+ return err
}
+ fmt.Print(output)
return nil
}
@@ -146,16 +148,12 @@ func runSkiptracePhone(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- if response.Data != nil && len(response.Data) > 0 {
- if err := utils.PrintJSON(response.Data); err != nil {
- return err
- }
- }
-
- if response.Message != "" {
- fmt.Println(response.Message)
+ output, err := formatter.FormatSkiptraceResults(response, IsJSONOutput())
+ if err != nil {
+ return err
}
+ fmt.Print(output)
return nil
}
@@ -174,15 +172,11 @@ func runSkiptraceEmail(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- if response.Data != nil && len(response.Data) > 0 {
- if err := utils.PrintJSON(response.Data); err != nil {
- return err
- }
- }
-
- if response.Message != "" {
- fmt.Println(response.Message)
+ output, err := formatter.FormatSkiptraceResults(response, IsJSONOutput())
+ if err != nil {
+ return err
}
+ fmt.Print(output)
return nil
}
diff --git a/cmd/sl.go b/cmd/sl.go
index 1efa9e2..c30725a 100644
--- a/cmd/sl.go
+++ b/cmd/sl.go
@@ -2,11 +2,11 @@ package cmd
import (
"fmt"
+
"git.db.org.ai/dborg/internal/client"
"git.db.org.ai/dborg/internal/config"
+ "git.db.org.ai/dborg/internal/formatter"
"git.db.org.ai/dborg/internal/models"
- "git.db.org.ai/dborg/internal/utils"
-
"github.com/spf13/cobra"
)
@@ -66,5 +66,10 @@ func runSLSearch(cmd *cobra.Command, args []string) error {
return nil
}
- return utils.PrintJSON(response.Results)
+ output, err := formatter.FormatSLResults(response, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+ fmt.Print(output)
+ return nil
}
diff --git a/cmd/x.go b/cmd/x.go
index 0820b27..ba18aa8 100644
--- a/cmd/x.go
+++ b/cmd/x.go
@@ -5,7 +5,8 @@ import (
"fmt"
"git.db.org.ai/dborg/internal/client"
"git.db.org.ai/dborg/internal/config"
- "git.db.org.ai/dborg/internal/utils"
+ "git.db.org.ai/dborg/internal/formatter"
+ "git.db.org.ai/dborg/internal/models"
"github.com/spf13/cobra"
)
@@ -94,16 +95,12 @@ func runXHistorySearch(cmd *cobra.Command, args []string) error {
return fmt.Errorf("API error: %s", response.Error)
}
- if len(response.PreviousUsernames) > 0 {
- return utils.PrintJSON(response.PreviousUsernames)
- } else if response.Response != "" {
- fmt.Println(response.Response)
- } else if response.Data != nil {
- return utils.PrintJSON(response.Data)
- } else {
- fmt.Println("No username history found")
+ output, err := formatter.FormatXHistory(response, IsJSONOutput())
+ if err != nil {
+ return err
}
+ fmt.Print(output)
return nil
}
@@ -116,7 +113,17 @@ func runXTweetsSearch(cmd *cobra.Command, args []string) error {
}
err = c.FetchTweetsStream(args[0], func(result json.RawMessage) error {
- fmt.Println(string(result))
+ var streamResp models.TweetsStreamResponse
+ if err := json.Unmarshal(result, &streamResp); err != nil {
+ return err
+ }
+
+ output, err := formatter.FormatXTweets(&streamResp, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+
+ fmt.Print(output)
return nil
})
@@ -140,7 +147,13 @@ func runXFirstFollowers(cmd *cobra.Command, args []string) error {
return err
}
- return utils.PrintJSON(response)
+ output, err := formatter.FormatXFirstFollowers(response, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+
+ fmt.Print(output)
+ return nil
}
func runXNotableFollowers(cmd *cobra.Command, args []string) error {
@@ -156,7 +169,13 @@ func runXNotableFollowers(cmd *cobra.Command, args []string) error {
return err
}
- return utils.PrintJSON(response)
+ output, err := formatter.FormatXNotableFollowers(response, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+
+ fmt.Print(output)
+ return nil
}
func runXReplies(cmd *cobra.Command, args []string) error {
@@ -169,7 +188,17 @@ func runXReplies(cmd *cobra.Command, args []string) error {
}
err = c.FetchRepliesStream(args[0], limit, func(result json.RawMessage) error {
- fmt.Println(string(result))
+ var streamResp models.TweetsStreamResponse
+ if err := json.Unmarshal(result, &streamResp); err != nil {
+ return err
+ }
+
+ output, err := formatter.FormatXReplies(&streamResp, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+
+ fmt.Print(output)
return nil
})
@@ -190,7 +219,17 @@ func runXSearch(cmd *cobra.Command, args []string) error {
}
err = c.SearchTweetsStream(args[0], limit, func(result json.RawMessage) error {
- fmt.Println(string(result))
+ var streamResp models.TweetsStreamResponse
+ if err := json.Unmarshal(result, &streamResp); err != nil {
+ return err
+ }
+
+ output, err := formatter.FormatXSearch(&streamResp, IsJSONOutput())
+ if err != nil {
+ return err
+ }
+
+ fmt.Print(output)
return nil
})
diff --git a/internal/formatter/admin.go b/internal/formatter/admin.go
new file mode 100644
index 0000000..914eaaa
--- /dev/null
+++ b/internal/formatter/admin.go
@@ -0,0 +1,187 @@
+package formatter
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "git.db.org.ai/dborg/internal/models"
+)
+
+func FormatAccountList(accounts []models.Account, asJSON bool) (string, error) {
+ if asJSON {
+ err := PrintColorizedJSON(accounts)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return "", nil
+ }
+
+ if len(accounts) == 0 {
+ return fmt.Sprintf("%s\n", Gray("No accounts found")), nil
+ }
+
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("\n%s\n", Bold(Cyan("Account List"))))
+ sb.WriteString(fmt.Sprintf("%s\n\n", Gray(strings.Repeat("─", 80))))
+
+ table := NewTable([]string{"Name", "API Key", "Credits", "Status", "Premium", "Created"})
+
+ for _, acc := range accounts {
+ creditsStr := fmt.Sprintf("%d", acc.Credits)
+ if acc.Unlimited {
+ creditsStr = Green("Unlimited")
+ } else if acc.Credits > 1000 {
+ creditsStr = Green(creditsStr)
+ } else if acc.Credits > 100 {
+ creditsStr = Yellow(creditsStr)
+ } else {
+ creditsStr = Red(creditsStr)
+ }
+
+ statusStr := Green("Active")
+ if acc.Disabled {
+ statusStr = Red("Disabled")
+ }
+
+ premiumStr := Gray("No")
+ if acc.IsPremium {
+ premiumStr = Magenta("Yes")
+ }
+
+ createdStr := Gray("-")
+ if acc.CreatedAt != nil {
+ if createdAt, ok := acc.CreatedAt.(string); ok && createdAt != "" {
+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
+ createdStr = t.Format("2006-01-02")
+ } else {
+ createdStr = createdAt
+ }
+ }
+ }
+
+ table.AddRow(
+ Bold(acc.Name),
+ Dim(acc.APIKey),
+ creditsStr,
+ statusStr,
+ premiumStr,
+ createdStr,
+ )
+ }
+
+ sb.WriteString(table.Render())
+ sb.WriteString(fmt.Sprintf("\n%s %d accounts\n", Blue("Total:"), len(accounts)))
+
+ return sb.String(), nil
+}
+
+func FormatAccountCreated(account *models.Account, message string, asJSON bool) (string, error) {
+ if asJSON {
+ response := map[string]interface{}{
+ "account": account,
+ "message": message,
+ }
+ err := PrintColorizedJSON(response)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return "", nil
+ }
+
+ if account == nil {
+ return fmt.Sprintf("%s\n", message), nil
+ }
+
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("\n%s\n\n", Bold(Green("✓ Account created successfully!"))))
+
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Name:"), Bold(account.Name)))
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("API Key:"), Bold(Yellow(account.APIKey))))
+
+ if account.Unlimited {
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Credits:"), Green("Unlimited")))
+ } else {
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Credits:"), FormatCredits(int64(account.Credits))))
+ }
+
+ if account.IsPremium {
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Premium:"), Magenta("Yes")))
+ } else {
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Premium:"), Gray("No")))
+ }
+
+ if account.Disabled {
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Status:"), Red("Disabled")))
+ } else {
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Status:"), Green("Active")))
+ }
+
+ sb.WriteString(fmt.Sprintf("\n%s\n", Dim("Save the API key securely - it cannot be retrieved later!")))
+
+ return sb.String(), nil
+}
+
+func FormatAccountDeleted(message string, asJSON bool) (string, error) {
+ if asJSON {
+ response := map[string]string{"message": message}
+ err := PrintColorizedJSON(response)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return "", nil
+ }
+
+ return fmt.Sprintf("%s %s\n", Green("✓"), message), nil
+}
+
+func FormatCreditsUpdated(message string, account *models.Account, asJSON bool) (string, error) {
+ if asJSON {
+ response := map[string]interface{}{
+ "message": message,
+ "account": account,
+ }
+ err := PrintColorizedJSON(response)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return "", nil
+ }
+
+ var sb strings.Builder
+
+ if account != nil {
+ sb.WriteString(fmt.Sprintf("\n%s\n\n", Bold(Green("✓ Credits updated successfully!"))))
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Account:"), Bold(account.Name)))
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("API Key:"), Dim(account.APIKey)))
+
+ if account.Unlimited {
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Credits:"), Green("Unlimited")))
+ } else {
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Credits:"), FormatCredits(int64(account.Credits))))
+ }
+ } else {
+ sb.WriteString(fmt.Sprintf("%s %s\n", Green("✓"), message))
+ }
+
+ return sb.String(), nil
+}
+
+func FormatAccountToggled(message string, asJSON bool) (string, error) {
+ if asJSON {
+ response := map[string]string{"message": message}
+ err := PrintColorizedJSON(response)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return "", nil
+ }
+
+ if strings.Contains(strings.ToLower(message), "enabled") {
+ return fmt.Sprintf("%s %s\n", Green("✓"), message), nil
+ } else if strings.Contains(strings.ToLower(message), "disabled") {
+ return fmt.Sprintf("%s %s\n", Yellow("⚠"), message), nil
+ }
+
+ return fmt.Sprintf("%s %s\n", Blue("ℹ"), message), nil
+}
diff --git a/internal/formatter/breachforum.go b/internal/formatter/breachforum.go
new file mode 100644
index 0000000..6f77fdf
--- /dev/null
+++ b/internal/formatter/breachforum.go
@@ -0,0 +1,186 @@
+package formatter
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "git.db.org.ai/dborg/internal/models"
+ "git.db.org.ai/dborg/internal/utils"
+)
+
+func FormatBreachForumResults(response *models.BreachForumSearchResponse, asJSON bool) error {
+ if asJSON {
+ return utils.PrintJSON(response)
+ }
+
+ PrintSection(fmt.Sprintf("BreachForum Search: %s", Bold(response.Query)))
+ fmt.Printf("%s: %d\n", Dim("Max Hits"), response.MaxHits)
+
+ if response.Results == nil {
+ PrintWarning("No results found")
+ return nil
+ }
+
+ results, ok := response.Results.(map[string]any)
+ if !ok {
+ return fmt.Errorf("unexpected results format")
+ }
+
+ if elapsed, ok := results["elapsed_time_micros"].(float64); ok {
+ fmt.Printf("%s: %s\n", Dim("Search Time"), formatElapsedTime(elapsed))
+ }
+
+ if numHits, ok := results["num_hits"].(float64); ok {
+ fmt.Printf("%s: %s\n", Dim("Total Results"), Yellow(fmt.Sprintf("%d", int(numHits))))
+ }
+
+ fmt.Println()
+
+ hits, ok := results["hits"].([]any)
+ if !ok || len(hits) == 0 {
+ PrintWarning("No results found")
+ return nil
+ }
+
+ for i, hit := range hits {
+ if hitMap, ok := hit.(map[string]any); ok {
+ formatBreachHit(hitMap, i+1, len(hits))
+ }
+ }
+
+ if errors, ok := results["errors"].([]any); ok && len(errors) > 0 {
+ fmt.Printf("\n%s\n", Bold(Red("Errors")))
+ for _, err := range errors {
+ fmt.Printf(" %s %s\n", StatusError.String(), err)
+ }
+ }
+
+ return nil
+}
+
+func formatBreachHit(hit map[string]any, index, total int) {
+ fmt.Printf("%s %s\n", Gray(fmt.Sprintf("[%d/%d]", index, total)), Bold("Result"))
+
+ if author, ok := hit["author"].(string); ok && author != "" {
+ cleanAuthor := strings.TrimSpace(strings.TrimPrefix(author, " "))
+ fmt.Printf(" %s: %s\n", Cyan("Author"), cleanAuthor)
+ }
+
+ if source, ok := hit["source"].(string); ok && source != "" {
+ sourceColor := getSourceColor(source)
+ fmt.Printf(" %s: %s\n", Cyan("Source"), Colorize(source, sourceColor))
+ }
+
+ if hitType, ok := hit["type"].(string); ok && hitType != "" {
+ typeColor := getTypeColor(hitType)
+ fmt.Printf(" %s: %s\n", Cyan("Type"), Colorize(hitType, typeColor))
+ }
+
+ if detDate, ok := hit["detection_date"].(string); ok && detDate != "" {
+ formattedDate := formatDetectionDate(detDate)
+ fmt.Printf(" %s: %s\n", Cyan("Detected"), formattedDate)
+ }
+
+ if content, ok := hit["content"].(string); ok && content != "" {
+ content = strings.TrimSpace(content)
+ if len(content) > 200 {
+ content = TruncateString(content, 200)
+ }
+
+ lines := strings.Split(content, "\n")
+ fmt.Printf(" %s:\n", Cyan("Content"))
+ for _, line := range lines {
+ if strings.TrimSpace(line) != "" {
+ fmt.Printf(" %s\n", Dim(line))
+ }
+ }
+ }
+
+ fmt.Println()
+}
+
+func formatElapsedTime(microseconds float64) string {
+ milliseconds := microseconds / 1000
+ if milliseconds < 1000 {
+ return fmt.Sprintf("%.2fms", milliseconds)
+ }
+ seconds := milliseconds / 1000
+ return fmt.Sprintf("%.2fs", seconds)
+}
+
+func formatDetectionDate(dateStr string) string {
+ t, err := time.Parse(time.RFC3339, dateStr)
+ if err != nil {
+ return dateStr
+ }
+
+ now := time.Now()
+ duration := now.Sub(t)
+
+ var timeAgo string
+ switch {
+ case duration.Hours() < 1:
+ timeAgo = fmt.Sprintf("%d minutes ago", int(duration.Minutes()))
+ case duration.Hours() < 24:
+ timeAgo = fmt.Sprintf("%d hours ago", int(duration.Hours()))
+ case duration.Hours() < 168:
+ days := int(duration.Hours() / 24)
+ if days == 1 {
+ timeAgo = "1 day ago"
+ } else {
+ timeAgo = fmt.Sprintf("%d days ago", days)
+ }
+ case duration.Hours() < 730:
+ weeks := int(duration.Hours() / 168)
+ if weeks == 1 {
+ timeAgo = "1 week ago"
+ } else {
+ timeAgo = fmt.Sprintf("%d weeks ago", weeks)
+ }
+ default:
+ months := int(duration.Hours() / 730)
+ if months == 1 {
+ timeAgo = "1 month ago"
+ } else {
+ timeAgo = fmt.Sprintf("%d months ago", months)
+ }
+ }
+
+ formattedDate := t.Format("2006-01-02 15:04")
+ return fmt.Sprintf("%s %s", Yellow(formattedDate), Gray(fmt.Sprintf("(%s)", timeAgo)))
+}
+
+func getSourceColor(source string) string {
+ source = strings.ToLower(source)
+ switch {
+ case strings.Contains(source, "leakbase"):
+ return ColorRed
+ case strings.Contains(source, "blackhat"):
+ return ColorMagenta
+ case strings.Contains(source, "hard-tm"):
+ return ColorYellow
+ case strings.Contains(source, "htdark"):
+ return ColorGray
+ case strings.Contains(source, "crdcrew"):
+ return ColorBlue
+ default:
+ return ColorCyan
+ }
+}
+
+func getTypeColor(hitType string) string {
+ hitType = strings.ToLower(hitType)
+ switch {
+ case strings.Contains(hitType, "credential"):
+ return ColorMagenta
+ case strings.Contains(hitType, "leak"):
+ return ColorRed
+ case strings.Contains(hitType, "breach"):
+ return ColorRed
+ case strings.Contains(hitType, "database"):
+ return ColorYellow
+ default:
+ return ColorCyan
+ }
+}
diff --git a/internal/formatter/bssid.go b/internal/formatter/bssid.go
new file mode 100644
index 0000000..06aa3cc
--- /dev/null
+++ b/internal/formatter/bssid.go
@@ -0,0 +1,47 @@
+package formatter
+
+import (
+ "fmt"
+
+ "git.db.org.ai/dborg/internal/models"
+ "git.db.org.ai/dborg/internal/utils"
+)
+
+func FormatBSSIDResults(response models.BSSIDLookupResponse, asJSON bool) error {
+ if asJSON {
+ return utils.PrintJSON(response)
+ }
+
+ if len(response) == 0 {
+ PrintWarning("No results found")
+ return nil
+ }
+
+ PrintSection("BSSID Lookup Results")
+
+ for i, result := range response {
+ if i > 0 {
+ PrintDivider()
+ }
+
+ fmt.Printf("\n%s: %s\n", Cyan("BSSID"), Bold(result.BSSID))
+
+ if result.Location != nil {
+ fmt.Printf("%s: %s\n", Cyan("Latitude"), Yellow(fmt.Sprintf("%.6f", result.Location.Latitude)))
+ fmt.Printf("%s: %s\n", Cyan("Longitude"), Yellow(fmt.Sprintf("%.6f", result.Location.Longitude)))
+ fmt.Printf("%s: %s\n", Cyan("Accuracy"), Dim(fmt.Sprintf("%d meters", result.Location.Accuracy)))
+
+ if result.GoogleMap != "" {
+ fmt.Printf("%s: %s\n", Cyan("Google Maps"), result.GoogleMap)
+ }
+ if result.OpenStreetMap != "" {
+ fmt.Printf("%s: %s\n", Cyan("OpenStreetMap"), result.OpenStreetMap)
+ }
+ } else {
+ fmt.Printf("%s\n", Gray("No location data available"))
+ }
+ }
+
+ fmt.Println()
+ return nil
+}
diff --git a/internal/formatter/buckets.go b/internal/formatter/buckets.go
new file mode 100644
index 0000000..9672b9d
--- /dev/null
+++ b/internal/formatter/buckets.go
@@ -0,0 +1,470 @@
+package formatter
+
+import (
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+
+ "git.db.org.ai/dborg/internal/models"
+ "git.db.org.ai/dborg/internal/utils"
+)
+
+func FormatBucketsResults(response *models.BucketsSearchResponse, asJSON bool) error {
+ if asJSON {
+ return utils.PrintJSON(response)
+ }
+
+ PrintSection("Bucket Search Results")
+
+ if response.Credits.Unlimited {
+ fmt.Printf("%s: %s\n", Dim("Credits"), Green("Unlimited"))
+ } else {
+ fmt.Printf("%s: %s\n", Dim("Credits Remaining"), FormatCredits(int64(response.Credits.Remaining)))
+ }
+
+ if response.Results == nil {
+ PrintWarning("No results found")
+ return nil
+ }
+
+ results, ok := response.Results.(map[string]any)
+ if !ok {
+ return fmt.Errorf("unexpected results format")
+ }
+
+ buckets, ok := results["buckets"].([]any)
+ if !ok || len(buckets) == 0 {
+ PrintWarning("No buckets found")
+ return nil
+ }
+
+ fmt.Printf("%s: %s\n\n", Dim("Total Buckets"), Yellow(fmt.Sprintf("%d", len(buckets))))
+
+ bucketGroups := groupBucketsByType(buckets)
+
+ for _, bType := range getOrderedTypes(bucketGroups) {
+ formatBucketGroup(bType, bucketGroups[bType])
+ }
+
+ printBucketStats(buckets)
+
+ return nil
+}
+
+func groupBucketsByType(buckets []any) map[string][]map[string]any {
+ groups := make(map[string][]map[string]any)
+
+ for _, bucket := range buckets {
+ if bucketMap, ok := bucket.(map[string]any); ok {
+ bucketType := "unknown"
+ if bt, ok := bucketMap["type"].(string); ok {
+ bucketType = bt
+ }
+ groups[bucketType] = append(groups[bucketType], bucketMap)
+ }
+ }
+
+ return groups
+}
+
+func getOrderedTypes(groups map[string][]map[string]any) []string {
+ var types []string
+ for t := range groups {
+ types = append(types, t)
+ }
+
+ sort.Slice(types, func(i, j int) bool {
+ order := map[string]int{"aws": 1, "gcp": 2, "azure": 3, "dos": 4, "unknown": 99}
+ iOrder, iOk := order[types[i]]
+ jOrder, jOk := order[types[j]]
+ if !iOk {
+ iOrder = 50
+ }
+ if !jOk {
+ jOrder = 50
+ }
+ if iOrder != jOrder {
+ return iOrder < jOrder
+ }
+ return len(groups[types[i]]) > len(groups[types[j]])
+ })
+
+ return types
+}
+
+func formatBucketGroup(bucketType string, buckets []map[string]any) {
+ typeHeader := getBucketTypeHeader(bucketType)
+ fmt.Printf("%s %s\n", typeHeader, Gray(fmt.Sprintf("(%d)", len(buckets))))
+ fmt.Println(Dim(strings.Repeat("─", 60)))
+
+ sort.Slice(buckets, func(i, j int) bool {
+ iCount := getFileCount(buckets[i])
+ jCount := getFileCount(buckets[j])
+ return iCount > jCount
+ })
+
+ for i, bucket := range buckets {
+ formatSingleBucket(bucket, i+1)
+ }
+
+ fmt.Println()
+}
+
+func formatSingleBucket(bucket map[string]any, index int) {
+ bucketName := "unknown"
+ if name, ok := bucket["bucket"].(string); ok {
+ bucketName = name
+ }
+
+ fileCount := getFileCount(bucket)
+ id := getID(bucket)
+
+ fmt.Printf(" %s %s\n", Gray(fmt.Sprintf("%d.", index)), truncateBucketName(bucketName))
+
+ if fileCount > 0 {
+ fileCountStr := formatFileCount(fileCount)
+ fmt.Printf(" %s: %s", Cyan("Files"), fileCountStr)
+ } else {
+ fmt.Printf(" %s: %s", Cyan("Files"), Gray("Empty"))
+ }
+
+ if id > 0 {
+ fmt.Printf(" %s %s", Dim("•"), Gray(fmt.Sprintf("ID: %d", id)))
+ }
+
+ fmt.Println()
+}
+
+func truncateBucketName(name string) string {
+ maxLen := 50
+ if len(name) <= maxLen {
+ return Bold(name)
+ }
+
+ parts := strings.Split(name, ".")
+ if len(parts) > 2 {
+ provider := parts[len(parts)-2] + "." + parts[len(parts)-1]
+ remaining := maxLen - len(provider) - 4
+ if remaining > 0 {
+ return Bold(TruncateString(strings.Join(parts[:len(parts)-2], "."), remaining) + "..." + provider)
+ }
+ }
+
+ return Bold(TruncateString(name, maxLen))
+}
+
+func formatFileCount(count int) string {
+ switch {
+ case count > 10000:
+ return Red(fmt.Sprintf("%s", formatNumber(count)))
+ case count > 1000:
+ return Yellow(fmt.Sprintf("%s", formatNumber(count)))
+ case count > 100:
+ return Green(fmt.Sprintf("%s", formatNumber(count)))
+ default:
+ return Cyan(fmt.Sprintf("%d", count))
+ }
+}
+
+func formatNumber(n int) string {
+ if n < 1000 {
+ return fmt.Sprintf("%d", n)
+ }
+
+ str := fmt.Sprintf("%d", n)
+ var result strings.Builder
+ for i, r := range str {
+ if i > 0 && (len(str)-i)%3 == 0 {
+ result.WriteString(",")
+ }
+ result.WriteRune(r)
+ }
+ return result.String()
+}
+
+func getBucketTypeHeader(bucketType string) string {
+ headers := map[string]string{
+ "aws": Bold(Yellow("☁ AWS S3 Buckets")),
+ "gcp": Bold(Blue("☁ Google Cloud Storage")),
+ "azure": Bold(Cyan("☁ Azure Storage")),
+ "dos": Bold(Green("☁ DigitalOcean Spaces")),
+ }
+
+ if header, ok := headers[bucketType]; ok {
+ return header
+ }
+ return Bold(Gray("☁ " + strings.Title(bucketType) + " Buckets"))
+}
+
+func getFileCount(bucket map[string]any) int {
+ if count, ok := bucket["fileCount"].(float64); ok {
+ return int(count)
+ }
+ return 0
+}
+
+func getID(bucket map[string]any) int {
+ if id, ok := bucket["id"].(float64); ok {
+ return int(id)
+ }
+ return 0
+}
+
+func printBucketStats(buckets []any) {
+ totalFiles := 0
+ emptyBuckets := 0
+ largeBuckets := 0
+
+ typeCounts := make(map[string]int)
+
+ for _, bucket := range buckets {
+ if bucketMap, ok := bucket.(map[string]any); ok {
+ fileCount := getFileCount(bucketMap)
+ totalFiles += fileCount
+
+ if fileCount == 0 {
+ emptyBuckets++
+ } else if fileCount > 1000 {
+ largeBuckets++
+ }
+
+ if bucketType, ok := bucketMap["type"].(string); ok {
+ typeCounts[bucketType]++
+ }
+ }
+ }
+
+ fmt.Printf("%s\n", Bold("Summary Statistics"))
+ fmt.Println(Dim(strings.Repeat("─", 60)))
+
+ fmt.Printf(" %s: %s\n", Cyan("Total Files"), Yellow(formatNumber(totalFiles)))
+ fmt.Printf(" %s: %s\n", Cyan("Average Files/Bucket"),
+ Yellow(fmt.Sprintf("%.1f", float64(totalFiles)/float64(len(buckets)))))
+
+ if emptyBuckets > 0 {
+ fmt.Printf(" %s: %s\n", Cyan("Empty Buckets"), Gray(fmt.Sprintf("%d", emptyBuckets)))
+ }
+
+ if largeBuckets > 0 {
+ fmt.Printf(" %s: %s\n", Cyan("Large Buckets (>1000 files)"),
+ Red(fmt.Sprintf("%d", largeBuckets)))
+ }
+
+ fmt.Println()
+}
+
+func FormatBucketFilesResults(response *models.BucketsFilesSearchResponse, asJSON bool) error {
+ if asJSON {
+ return utils.PrintJSON(response)
+ }
+
+ PrintSection("Bucket Files Search Results")
+
+ if response.Credits.Unlimited {
+ fmt.Printf("%s: %s\n", Dim("Credits"), Green("Unlimited"))
+ } else {
+ fmt.Printf("%s: %s\n", Dim("Credits Remaining"), FormatCredits(int64(response.Credits.Remaining)))
+ }
+
+ if response.Results == nil {
+ PrintWarning("No results found")
+ return nil
+ }
+
+ results, ok := response.Results.(map[string]any)
+ if !ok {
+ resultsJSON, err := json.MarshalIndent(response.Results, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to format results: %w", err)
+ }
+ fmt.Println(string(resultsJSON))
+ return nil
+ }
+
+ files, ok := results["files"].([]any)
+ if !ok || len(files) == 0 {
+ PrintWarning("No files found")
+ return nil
+ }
+
+ fmt.Printf("%s: %s\n\n", Dim("Total Files"), Yellow(fmt.Sprintf("%d", len(files))))
+
+ fileGroups := groupFilesByBucket(files)
+
+ for bucket, bucketFiles := range fileGroups {
+ formatFileGroup(bucket, bucketFiles)
+ }
+
+ return nil
+}
+
+func groupFilesByBucket(files []any) map[string][]map[string]any {
+ groups := make(map[string][]map[string]any)
+
+ for _, file := range files {
+ if fileMap, ok := file.(map[string]any); ok {
+ bucket := "unknown"
+ if b, ok := fileMap["bucket"].(string); ok && b != "" {
+ bucket = b
+ } else if url, ok := fileMap["url"].(string); ok && url != "" {
+ bucket = extractBucketFromURL(url)
+ }
+ groups[bucket] = append(groups[bucket], fileMap)
+ }
+ }
+
+ return groups
+}
+
+func extractBucketFromURL(url string) string {
+ url = strings.TrimPrefix(url, "https://")
+ url = strings.TrimPrefix(url, "http://")
+
+ parts := strings.Split(url, "/")
+ if len(parts) > 0 {
+ return parts[0]
+ }
+
+ return "unknown"
+}
+
+func formatFileGroup(bucket string, files []map[string]any) {
+ fmt.Printf("%s %s\n", Bold(truncateBucketName(bucket)), Gray(fmt.Sprintf("(%d files)", len(files))))
+ fmt.Println(Dim(strings.Repeat("─", 60)))
+
+ for i, file := range files {
+ formatSingleFile(file, i+1)
+ }
+
+ fmt.Println()
+}
+
+func formatSingleFile(file map[string]any, index int) {
+ fileName := "unknown"
+ url := ""
+
+ if name, ok := file["file"].(string); ok && name != "" {
+ fileName = name
+ } else if u, ok := file["url"].(string); ok && u != "" {
+ url = u
+ fileName = extractFileNameFromURL(u)
+ }
+
+ fmt.Printf(" %s %s\n", Gray(fmt.Sprintf("%d.", index)), formatFileName(fileName))
+
+ if url != "" {
+ fmt.Printf(" %s: %s\n", Cyan("URL"), Dim(url))
+ }
+
+ if size, ok := file["size"].(float64); ok && size > 0 {
+ fmt.Printf(" %s: %s\n", Cyan("Size"), formatFileSize(int64(size)))
+ }
+
+ if modified, ok := file["lastModified"].(string); ok && modified != "" {
+ fmt.Printf(" %s: %s\n", Cyan("Modified"), Dim(modified))
+ }
+}
+
+func extractFileNameFromURL(url string) string {
+ parts := strings.Split(url, "/")
+ if len(parts) > 0 {
+ fileName := parts[len(parts)-1]
+
+ if qIndex := strings.Index(fileName, "?"); qIndex != -1 {
+ fileName = fileName[:qIndex]
+ }
+
+ if hIndex := strings.Index(fileName, "#"); hIndex != -1 {
+ fileName = fileName[:hIndex]
+ }
+
+ if fileName == "" {
+ return "index"
+ }
+
+ return fileName
+ }
+ return "unknown"
+}
+
+func formatFileName(name string) string {
+ if name == "" || name == "unknown" {
+ return Gray("(unnamed file)")
+ }
+
+ parts := strings.Split(name, "/")
+ if len(parts) > 1 {
+ path := strings.Join(parts[:len(parts)-1], "/")
+ file := parts[len(parts)-1]
+
+ if file == "" {
+ file = "(directory)"
+ }
+
+ file = decodeURLEncoding(file)
+
+ ext := getFileExtension(file)
+ color := getExtensionColor(ext)
+
+ return Gray(path+"/") + Colorize(file, color)
+ }
+
+ name = decodeURLEncoding(name)
+ ext := getFileExtension(name)
+ color := getExtensionColor(ext)
+
+ return Colorize(name, color)
+}
+
+func decodeURLEncoding(s string) string {
+ decoded := strings.ReplaceAll(s, "%20", " ")
+ decoded = strings.ReplaceAll(decoded, "%28", "(")
+ decoded = strings.ReplaceAll(decoded, "%29", ")")
+ decoded = strings.ReplaceAll(decoded, "_", " ")
+
+ return decoded
+}
+
+func getFileExtension(filename string) string {
+ parts := strings.Split(filename, ".")
+ if len(parts) > 1 {
+ return strings.ToLower(parts[len(parts)-1])
+ }
+ return ""
+}
+
+func getExtensionColor(ext string) string {
+ switch ext {
+ case "pdf", "doc", "docx", "txt":
+ return ColorBlue
+ case "jpg", "jpeg", "png", "gif", "svg":
+ return ColorGreen
+ case "mp4", "avi", "mov", "mkv":
+ return ColorMagenta
+ case "zip", "tar", "gz", "rar":
+ return ColorYellow
+ case "sql", "db", "sqlite":
+ return ColorRed
+ case "json", "xml", "yaml", "yml":
+ return ColorCyan
+ default:
+ return ColorWhite
+ }
+}
+
+func formatFileSize(bytes int64) string {
+ color := ColorWhite
+ switch {
+ case bytes > 1024*1024*100:
+ color = ColorRed
+ case bytes > 1024*1024*10:
+ color = ColorYellow
+ case bytes > 1024*1024:
+ color = ColorGreen
+ default:
+ color = ColorCyan
+ }
+
+ return Colorize(FormatBytes(bytes), color)
+}
diff --git a/internal/formatter/buckets_test.go b/internal/formatter/buckets_test.go
new file mode 100644
index 0000000..eabddf0
--- /dev/null
+++ b/internal/formatter/buckets_test.go
@@ -0,0 +1,509 @@
+package formatter
+
+import (
+ "bytes"
+ "os"
+ "testing"
+
+ "git.db.org.ai/dborg/internal/models"
+)
+
+func TestFormatBucketsResults(t *testing.T) {
+ tests := []struct {
+ name string
+ response *models.BucketsSearchResponse
+ asJSON bool
+ wantErr bool
+ }{
+ {
+ name: "format_buckets_with_multiple_types",
+ response: &models.BucketsSearchResponse{
+ Credits: models.CreditsInfo{
+ Unlimited: true,
+ },
+ Results: map[string]any{
+ "buckets": []any{
+ map[string]any{
+ "bucket": "test-bucket.s3.amazonaws.com",
+ "fileCount": float64(1500),
+ "id": float64(1),
+ "type": "aws",
+ },
+ map[string]any{
+ "bucket": "another.s3-eu-west-1.amazonaws.com",
+ "fileCount": float64(250),
+ "id": float64(2),
+ "type": "aws",
+ },
+ map[string]any{
+ "bucket": "storage.googleapis.com",
+ "fileCount": float64(10000),
+ "id": float64(3),
+ "type": "gcp",
+ },
+ map[string]any{
+ "bucket": "spaces.digitaloceanspaces.com",
+ "fileCount": float64(0),
+ "id": float64(4),
+ "type": "dos",
+ },
+ },
+ },
+ },
+ asJSON: false,
+ wantErr: false,
+ },
+ {
+ name: "format_empty_buckets",
+ response: &models.BucketsSearchResponse{
+ Credits: models.CreditsInfo{
+ Remaining: 100,
+ Unlimited: false,
+ },
+ Results: nil,
+ },
+ asJSON: false,
+ wantErr: false,
+ },
+ {
+ name: "format_json_output",
+ response: &models.BucketsSearchResponse{
+ Credits: models.CreditsInfo{
+ Unlimited: true,
+ },
+ Results: map[string]any{
+ "buckets": []any{
+ map[string]any{
+ "bucket": "test.s3.amazonaws.com",
+ "type": "aws",
+ },
+ },
+ },
+ },
+ asJSON: true,
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ err := FormatBucketsResults(tt.response, tt.asJSON)
+
+ w.Close()
+ var buf bytes.Buffer
+ buf.ReadFrom(r)
+ os.Stdout = oldStdout
+
+ if (err != nil) != tt.wantErr {
+ t.Errorf("FormatBucketsResults() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ output := buf.String()
+ if !tt.asJSON && tt.response.Results != nil {
+ if output == "" {
+ t.Error("Expected non-empty output for non-JSON formatting")
+ }
+ }
+ })
+ }
+}
+
+func TestFormatBucketFilesResults(t *testing.T) {
+ tests := []struct {
+ name string
+ response *models.BucketsFilesSearchResponse
+ asJSON bool
+ wantErr bool
+ }{
+ {
+ name: "format_files_grouped_by_bucket",
+ response: &models.BucketsFilesSearchResponse{
+ Credits: models.CreditsInfo{
+ Unlimited: true,
+ },
+ Results: map[string]any{
+ "files": []any{
+ map[string]any{
+ "bucket": "test-bucket.s3.amazonaws.com",
+ "file": "documents/report.pdf",
+ "url": "https://test-bucket.s3.amazonaws.com/documents/report.pdf",
+ "size": float64(1024000),
+ "lastModified": "2024-01-01T00:00:00Z",
+ },
+ map[string]any{
+ "bucket": "test-bucket.s3.amazonaws.com",
+ "file": "images/logo.png",
+ "url": "https://test-bucket.s3.amazonaws.com/images/logo.png",
+ "size": float64(50000),
+ },
+ map[string]any{
+ "bucket": "another-bucket.s3.amazonaws.com",
+ "file": "data.json",
+ "url": "https://another-bucket.s3.amazonaws.com/data.json",
+ },
+ },
+ },
+ },
+ asJSON: false,
+ wantErr: false,
+ },
+ {
+ name: "format_empty_files",
+ response: &models.BucketsFilesSearchResponse{
+ Credits: models.CreditsInfo{
+ Remaining: 50,
+ Unlimited: false,
+ },
+ Results: map[string]any{
+ "files": []any{},
+ },
+ },
+ asJSON: false,
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ err := FormatBucketFilesResults(tt.response, tt.asJSON)
+
+ w.Close()
+ var buf bytes.Buffer
+ buf.ReadFrom(r)
+ os.Stdout = oldStdout
+
+ if (err != nil) != tt.wantErr {
+ t.Errorf("FormatBucketFilesResults() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ })
+ }
+}
+
+func TestGetBucketTypeHeader(t *testing.T) {
+ tests := []struct {
+ name string
+ bucketType string
+ wantNonEmpty bool
+ }{
+ {
+ name: "aws_header",
+ bucketType: "aws",
+ wantNonEmpty: true,
+ },
+ {
+ name: "gcp_header",
+ bucketType: "gcp",
+ wantNonEmpty: true,
+ },
+ {
+ name: "dos_header",
+ bucketType: "dos",
+ wantNonEmpty: true,
+ },
+ {
+ name: "unknown_header",
+ bucketType: "custom",
+ wantNonEmpty: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := getBucketTypeHeader(tt.bucketType)
+ if tt.wantNonEmpty && got == "" {
+ t.Errorf("getBucketTypeHeader() returned empty string for %s", tt.bucketType)
+ }
+ })
+ }
+}
+
+func TestFormatFileCount(t *testing.T) {
+ tests := []struct {
+ name string
+ count int
+ want bool
+ }{
+ {
+ name: "small_count",
+ count: 10,
+ want: true,
+ },
+ {
+ name: "medium_count",
+ count: 500,
+ want: true,
+ },
+ {
+ name: "large_count",
+ count: 5000,
+ want: true,
+ },
+ {
+ name: "very_large_count",
+ count: 50000,
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := formatFileCount(tt.count)
+ if tt.want && got == "" {
+ t.Errorf("formatFileCount() returned empty for count %d", tt.count)
+ }
+ })
+ }
+}
+
+func TestFormatNumber(t *testing.T) {
+ tests := []struct {
+ name string
+ n int
+ want string
+ }{
+ {
+ name: "small_number",
+ n: 999,
+ want: "999",
+ },
+ {
+ name: "thousand",
+ n: 1000,
+ want: "1,000",
+ },
+ {
+ name: "million",
+ n: 1000000,
+ want: "1,000,000",
+ },
+ {
+ name: "random_large",
+ n: 12345678,
+ want: "12,345,678",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := formatNumber(tt.n)
+ if got != tt.want {
+ t.Errorf("formatNumber() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGetExtensionColor(t *testing.T) {
+ tests := []struct {
+ name string
+ ext string
+ want string
+ }{
+ {
+ name: "pdf_extension",
+ ext: "pdf",
+ want: ColorBlue,
+ },
+ {
+ name: "image_extension",
+ ext: "png",
+ want: ColorGreen,
+ },
+ {
+ name: "video_extension",
+ ext: "mp4",
+ want: ColorMagenta,
+ },
+ {
+ name: "archive_extension",
+ ext: "zip",
+ want: ColorYellow,
+ },
+ {
+ name: "database_extension",
+ ext: "sql",
+ want: ColorRed,
+ },
+ {
+ name: "config_extension",
+ ext: "json",
+ want: ColorCyan,
+ },
+ {
+ name: "unknown_extension",
+ ext: "xyz",
+ want: ColorWhite,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := getExtensionColor(tt.ext)
+ if got != tt.want {
+ t.Errorf("getExtensionColor() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestTruncateBucketName(t *testing.T) {
+ tests := []struct {
+ name string
+ bucket string
+ wantLen int
+ }{
+ {
+ name: "short_name",
+ bucket: "test-bucket",
+ wantLen: 50,
+ },
+ {
+ name: "long_aws_bucket",
+ bucket: "very-long-bucket-name-that-exceeds-the-limit.s3.amazonaws.com",
+ wantLen: 60,
+ },
+ {
+ name: "long_gcp_bucket",
+ bucket: "extremely-long-google-cloud-storage-bucket-name.storage.googleapis.com",
+ wantLen: 80,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := truncateBucketName(tt.bucket)
+ if len(got) > tt.wantLen {
+ t.Errorf("truncateBucketName() length = %d, want <= %d", len(got), tt.wantLen)
+ }
+ })
+ }
+}
+
+func TestExtractFileNameFromURL(t *testing.T) {
+ tests := []struct {
+ name string
+ url string
+ want string
+ }{
+ {
+ name: "simple_filename",
+ url: "https://bucket.s3.amazonaws.com/file.pdf",
+ want: "file.pdf",
+ },
+ {
+ name: "filename_with_path",
+ url: "https://bucket.s3.amazonaws.com/path/to/document.docx",
+ want: "document.docx",
+ },
+ {
+ name: "filename_with_query",
+ url: "https://bucket.s3.amazonaws.com/image.jpg?version=123",
+ want: "image.jpg",
+ },
+ {
+ name: "encoded_filename",
+ url: "https://bucket.s3.amazonaws.com/my%20file%20name.pdf",
+ want: "my%20file%20name.pdf",
+ },
+ {
+ name: "no_filename",
+ url: "https://bucket.s3.amazonaws.com/",
+ want: "index",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := extractFileNameFromURL(tt.url)
+ if got != tt.want {
+ t.Errorf("extractFileNameFromURL() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestExtractBucketFromURL(t *testing.T) {
+ tests := []struct {
+ name string
+ url string
+ want string
+ }{
+ {
+ name: "aws_s3_url",
+ url: "https://my-bucket.s3.amazonaws.com/file.pdf",
+ want: "my-bucket.s3.amazonaws.com",
+ },
+ {
+ name: "digitalocean_spaces",
+ url: "https://space.nyc3.digitaloceanspaces.com/path/file.jpg",
+ want: "space.nyc3.digitaloceanspaces.com",
+ },
+ {
+ name: "gcp_storage",
+ url: "https://bucket.storage.googleapis.com/data.json",
+ want: "bucket.storage.googleapis.com",
+ },
+ {
+ name: "http_url",
+ url: "http://bucket.example.com/file.txt",
+ want: "bucket.example.com",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := extractBucketFromURL(tt.url)
+ if got != tt.want {
+ t.Errorf("extractBucketFromURL() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDecodeURLEncoding(t *testing.T) {
+ tests := []struct {
+ name string
+ s string
+ want string
+ }{
+ {
+ name: "spaces",
+ s: "my%20file%20name.pdf",
+ want: "my file name.pdf",
+ },
+ {
+ name: "parentheses",
+ s: "document%28final%29.docx",
+ want: "document(final).docx",
+ },
+ {
+ name: "underscores",
+ s: "test_file_name.jpg",
+ want: "test file name.jpg",
+ },
+ {
+ name: "mixed",
+ s: "WhatsApp%20Image_2021%2810%29.jpeg",
+ want: "WhatsApp Image 2021(10).jpeg",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := decodeURLEncoding(tt.s)
+ if got != tt.want {
+ t.Errorf("decodeURLEncoding() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/formatter/dns.go b/internal/formatter/dns.go
new file mode 100644
index 0000000..6abd5b0
--- /dev/null
+++ b/internal/formatter/dns.go
@@ -0,0 +1,57 @@
+package formatter
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "git.db.org.ai/dborg/internal/models"
+)
+
+func FormatDNSResults(result *models.DomainResult, asJSON bool) (string, error) {
+ if asJSON {
+ data, err := json.Marshal(result)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return string(data), nil
+ }
+
+ var sb strings.Builder
+
+ sb.WriteString("\n")
+
+ domainColor := result.Domain
+ if result.Status == "ACTIVE" {
+ domainColor = Green(result.Domain)
+ } else if result.Status == "INACTIVE" {
+ domainColor = Red(result.Domain)
+ } else {
+ domainColor = Yellow(result.Domain)
+ }
+
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Domain:"), Bold(domainColor)))
+
+ statusIndicator := StatusPending
+ if result.Status == "ACTIVE" {
+ statusIndicator = StatusSuccess
+ } else if result.Status == "INACTIVE" {
+ statusIndicator = StatusError
+ } else {
+ statusIndicator = StatusWarning
+ }
+ sb.WriteString(fmt.Sprintf("%s %s %s\n", Cyan("Status:"), statusIndicator.String(), result.Status))
+
+ if result.Title != "" {
+ sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Title:"), result.Title))
+ }
+
+ if len(result.Tech) > 0 {
+ sb.WriteString(fmt.Sprintf("%s\n", Cyan("Tech Stack:")))
+ for _, tech := range result.Tech {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", Dim("•"), Green(tech)))
+ }
+ }
+
+ return sb.String(), nil
+}
diff --git a/internal/formatter/files.go b/internal/formatter/files.go
new file mode 100644
index 0000000..7dedb53
--- /dev/null
+++ b/internal/formatter/files.go
@@ -0,0 +1,79 @@
+package formatter
+
+import (
+ "fmt"
+ "strings"
+
+ "git.db.org.ai/dborg/internal/models"
+ "git.db.org.ai/dborg/internal/utils"
+)
+
+func FormatFilesResults(response models.OpenDirectorySearchResponse, asJSON bool) error {
+ if asJSON {
+ return utils.PrintJSON(response)
+ }
+
+ PrintSection("Open Directory File Search Results")
+
+ if len(response) == 0 {
+ PrintWarning("No results found")
+ return nil
+ }
+
+ hits, ok := response["hits"].(map[string]interface{})
+ if !ok {
+ PrintWarning("Invalid response format")
+ return nil
+ }
+
+ total, _ := hits["total"].(map[string]interface{})
+ totalValue := int64(0)
+ if val, ok := total["value"].(float64); ok {
+ totalValue = int64(val)
+ }
+
+ took := int64(0)
+ if val, ok := response["took"].(float64); ok {
+ took = int64(val)
+ }
+
+ fmt.Printf("Total results: %s%d%s (showing first results)\n", ColorCyan, totalValue, ColorReset)
+ fmt.Printf("Query time: %s%dms%s\n\n", ColorGray, took, ColorReset)
+
+ hitsArray, ok := hits["hits"].([]interface{})
+ if !ok || len(hitsArray) == 0 {
+ PrintWarning("No files found")
+ return nil
+ }
+
+ for i, hit := range hitsArray {
+ hitMap, ok := hit.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ source, ok := hitMap["_source"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ filename, _ := source["filename"].(string)
+ extension, _ := source["extension"].(string)
+ url, _ := source["url"].(string)
+ score, _ := hitMap["_score"].(float64)
+
+ fmt.Printf("%s[%d]%s %s%s%s\n", ColorGray, i+1, ColorReset, ColorYellow, filename, ColorReset)
+ fmt.Printf(" Extension: %s%s%s\n", ColorCyan, extension, ColorReset)
+ fmt.Printf(" Score: %s%.2f%s\n", ColorGray, score, ColorReset)
+
+ displayURL := strings.ReplaceAll(url, "\u0026", "&")
+
+ fmt.Printf(" URL: %s%s%s\n", ColorBlue, displayURL, ColorReset)
+
+ if i < len(hitsArray)-1 {
+ fmt.Println()
+ }
+ }
+
+ return nil
+}
diff --git a/internal/formatter/formatter.go b/internal/formatter/formatter.go
new file mode 100644
index 0000000..4d65c60
--- /dev/null
+++ b/internal/formatter/formatter.go
@@ -0,0 +1,478 @@
+package formatter
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "text/tabwriter"
+
+ "git.db.org.ai/dborg/internal/utils"
+)
+
+const (
+ ColorReset = "\033[0m"
+ ColorRed = "\033[31m"
+ ColorGreen = "\033[32m"
+ ColorYellow = "\033[33m"
+ ColorBlue = "\033[34m"
+ ColorMagenta = "\033[35m"
+ ColorCyan = "\033[36m"
+ ColorGray = "\033[90m"
+ ColorWhite = "\033[37m"
+ ColorBold = "\033[1m"
+ ColorDim = "\033[2m"
+)
+
+type Formatter interface {
+ Format(data any) (string, error)
+ Print(data any) error
+}
+
+type OutputMode int
+
+const (
+ ModeJSON OutputMode = iota
+ ModePretty
+)
+
+type BaseFormatter struct {
+ mode OutputMode
+ writer io.Writer
+}
+
+func NewFormatter(isJSON bool) *BaseFormatter {
+ mode := ModePretty
+ if isJSON {
+ mode = ModeJSON
+ }
+ return &BaseFormatter{
+ mode: mode,
+ writer: os.Stdout,
+ }
+}
+
+func (f *BaseFormatter) IsJSON() bool {
+ return f.mode == ModeJSON
+}
+
+func (f *BaseFormatter) FormatJSON(data any) error {
+ return utils.PrintJSON(data)
+}
+
+func isTerminal() bool {
+ fileInfo, err := os.Stdout.Stat()
+ if err != nil {
+ return false
+ }
+ return (fileInfo.Mode() & os.ModeCharDevice) != 0
+}
+
+func GetTerminalWidth() int {
+ return 80
+}
+
+func Colorize(text string, color string) string {
+ if !isTerminal() {
+ return text
+ }
+ return color + text + ColorReset
+}
+
+func Bold(text string) string {
+ return Colorize(text, ColorBold)
+}
+
+func Dim(text string) string {
+ return Colorize(text, ColorDim)
+}
+
+func Red(text string) string {
+ return Colorize(text, ColorRed)
+}
+
+func Green(text string) string {
+ return Colorize(text, ColorGreen)
+}
+
+func Yellow(text string) string {
+ return Colorize(text, ColorYellow)
+}
+
+func Blue(text string) string {
+ return Colorize(text, ColorBlue)
+}
+
+func Cyan(text string) string {
+ return Colorize(text, ColorCyan)
+}
+
+func Gray(text string) string {
+ return Colorize(text, ColorGray)
+}
+
+func Magenta(text string) string {
+ return Colorize(text, ColorMagenta)
+}
+
+type CreditsDisplay struct {
+ Current int64
+ Used int64
+ Remaining int64
+ Operation string
+}
+
+func FormatCredits(credits int64) string {
+ var color string
+ switch {
+ case credits > 1000:
+ color = ColorGreen
+ case credits > 100:
+ color = ColorYellow
+ default:
+ color = ColorRed
+ }
+ return Colorize(fmt.Sprintf("%d", credits), color)
+}
+
+func FormatCreditsWithLabel(credits int64, label string) string {
+ return fmt.Sprintf("%s: %s", Dim(label), FormatCredits(credits))
+}
+
+func PrintCreditsInfo(display *CreditsDisplay) {
+ if display == nil {
+ return
+ }
+
+ fmt.Printf("%s\n", Bold("Credits Information"))
+ if display.Operation != "" {
+ fmt.Printf(" %s: %s\n", Dim("Operation"), display.Operation)
+ }
+ if display.Current > 0 {
+ fmt.Printf(" %s\n", FormatCreditsWithLabel(display.Current, "Current"))
+ }
+ if display.Used > 0 {
+ fmt.Printf(" %s\n", FormatCreditsWithLabel(display.Used, "Used"))
+ }
+ if display.Remaining > 0 {
+ fmt.Printf(" %s\n", FormatCreditsWithLabel(display.Remaining, "Remaining"))
+ }
+ fmt.Println()
+}
+
+type ProgressBar struct {
+ Total int
+ Current int
+ Width int
+ Label string
+}
+
+func NewProgressBar(total int, label string) *ProgressBar {
+ return &ProgressBar{
+ Total: total,
+ Width: 40,
+ Label: label,
+ }
+}
+
+func (p *ProgressBar) Update(current int) {
+ p.Current = current
+}
+
+func (p *ProgressBar) Render() string {
+ if p.Total <= 0 {
+ return ""
+ }
+
+ percentage := float64(p.Current) / float64(p.Total)
+ filled := int(percentage * float64(p.Width))
+
+ if filled > p.Width {
+ filled = p.Width
+ }
+
+ bar := strings.Repeat("█", filled) + strings.Repeat("░", p.Width-filled)
+ percentStr := fmt.Sprintf("%.1f%%", percentage*100)
+
+ var barColor string
+ switch {
+ case percentage >= 1.0:
+ barColor = ColorGreen
+ case percentage >= 0.5:
+ barColor = ColorYellow
+ default:
+ barColor = ColorCyan
+ }
+
+ coloredBar := Colorize(bar, barColor)
+
+ if p.Label != "" {
+ return fmt.Sprintf("%s [%s] %s (%d/%d)",
+ Dim(p.Label), coloredBar, Bold(percentStr), p.Current, p.Total)
+ }
+
+ return fmt.Sprintf("[%s] %s (%d/%d)",
+ coloredBar, Bold(percentStr), p.Current, p.Total)
+}
+
+func (p *ProgressBar) Print() {
+ fmt.Printf("\r%s", p.Render())
+}
+
+func (p *ProgressBar) Finish() {
+ p.Current = p.Total
+ fmt.Printf("\r%s\n", p.Render())
+}
+
+type TableFormatter struct {
+ headers []string
+ rows [][]string
+ writer *tabwriter.Writer
+}
+
+func NewTable(headers []string) *TableFormatter {
+ return &TableFormatter{
+ headers: headers,
+ rows: make([][]string, 0),
+ writer: tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0),
+ }
+}
+
+func (t *TableFormatter) AddRow(columns ...string) {
+ t.rows = append(t.rows, columns)
+}
+
+func (t *TableFormatter) AddRows(rows [][]string) {
+ t.rows = append(t.rows, rows...)
+}
+
+func (t *TableFormatter) Render() string {
+ var buf bytes.Buffer
+ w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0)
+
+ for i, h := range t.headers {
+ if i > 0 {
+ fmt.Fprint(w, "\t")
+ }
+ fmt.Fprint(w, Bold(h))
+ }
+ fmt.Fprintln(w)
+
+ for _, row := range t.rows {
+ for i, col := range row {
+ if i > 0 {
+ fmt.Fprint(w, "\t")
+ }
+ fmt.Fprint(w, col)
+ }
+ fmt.Fprintln(w)
+ }
+
+ w.Flush()
+ return buf.String()
+}
+
+func (t *TableFormatter) Print() {
+ fmt.Print(t.Render())
+}
+
+func (t *TableFormatter) PrintJSON() error {
+ data := make([]map[string]string, 0, len(t.rows))
+
+ for _, row := range t.rows {
+ rowMap := make(map[string]string)
+ for i, col := range row {
+ if i < len(t.headers) {
+ rowMap[t.headers[i]] = col
+ }
+ }
+ data = append(data, rowMap)
+ }
+
+ return utils.PrintJSON(data)
+}
+
+type KeyValue struct {
+ Key string
+ Value string
+}
+
+func FormatKeyValue(key, value string) string {
+ return fmt.Sprintf("%s: %s", Cyan(key), value)
+}
+
+func FormatKeyValueList(items []KeyValue) string {
+ var buf bytes.Buffer
+ for i, item := range items {
+ if i > 0 {
+ buf.WriteString("\n")
+ }
+ buf.WriteString(FormatKeyValue(item.Key, item.Value))
+ }
+ return buf.String()
+}
+
+func PrintKeyValue(key, value string) {
+ fmt.Println(FormatKeyValue(key, value))
+}
+
+func PrintSection(title string) {
+ fmt.Printf("\n%s\n%s\n", Bold(title), strings.Repeat("─", len(title)))
+}
+
+func PrintDivider() {
+ if isTerminal() {
+ fmt.Println(Dim(strings.Repeat("─", 80)))
+ } else {
+ fmt.Println(strings.Repeat("-", 80))
+ }
+}
+
+type StatusIndicator int
+
+const (
+ StatusSuccess StatusIndicator = iota
+ StatusWarning
+ StatusError
+ StatusInfo
+ StatusPending
+)
+
+func (s StatusIndicator) String() string {
+ switch s {
+ case StatusSuccess:
+ return Green("✓")
+ case StatusWarning:
+ return Yellow("⚠")
+ case StatusError:
+ return Red("✗")
+ case StatusInfo:
+ return Blue("ℹ")
+ case StatusPending:
+ return Gray("●")
+ default:
+ return "?"
+ }
+}
+
+func FormatStatus(status StatusIndicator, message string) string {
+ return fmt.Sprintf("%s %s", status.String(), message)
+}
+
+func PrintStatus(status StatusIndicator, message string) {
+ fmt.Println(FormatStatus(status, message))
+}
+
+func PrintSuccess(message string) {
+ PrintStatus(StatusSuccess, message)
+}
+
+func PrintWarning(message string) {
+ PrintStatus(StatusWarning, message)
+}
+
+func PrintError(message string) {
+ PrintStatus(StatusError, message)
+}
+
+func PrintInfo(message string) {
+ PrintStatus(StatusInfo, message)
+}
+
+func FormatBytes(bytes int64) string {
+ const unit = 1024
+ if bytes < unit {
+ return fmt.Sprintf("%d B", bytes)
+ }
+ div, exp := int64(unit), 0
+ for n := bytes / unit; n >= unit; n /= unit {
+ div *= unit
+ exp++
+ }
+ return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
+}
+
+func FormatPercentage(value, total int64) string {
+ if total == 0 {
+ return "0.0%"
+ }
+ percentage := float64(value) / float64(total) * 100
+ return fmt.Sprintf("%.1f%%", percentage)
+}
+
+func TruncateString(s string, maxLen int) string {
+ if len(s) <= maxLen {
+ return s
+ }
+ if maxLen <= 3 {
+ return s[:maxLen]
+ }
+ return s[:maxLen-3] + "..."
+}
+
+func PadRight(s string, width int) string {
+ if len(s) >= width {
+ return s
+ }
+ return s + strings.Repeat(" ", width-len(s))
+}
+
+func PadLeft(s string, width int) string {
+ if len(s) >= width {
+ return s
+ }
+ return strings.Repeat(" ", width-len(s)) + s
+}
+
+func FormatList(items []string, bullet string) string {
+ if bullet == "" {
+ bullet = "•"
+ }
+ var buf bytes.Buffer
+ for i, item := range items {
+ if i > 0 {
+ buf.WriteString("\n")
+ }
+ buf.WriteString(fmt.Sprintf("%s %s", Dim(bullet), item))
+ }
+ return buf.String()
+}
+
+func PrintList(items []string) {
+ fmt.Println(FormatList(items, "•"))
+}
+
+func StreamJSON(w io.Writer, data any) error {
+ encoder := json.NewEncoder(w)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(data)
+}
+
+func StreamNDJSON(w io.Writer, data any) error {
+ encoder := json.NewEncoder(w)
+ return encoder.Encode(data)
+}
+
+func PrintColorizedJSON(data any) error {
+ return utils.PrintJSON(data)
+}
+
+func FormatColorizedJSON(data any) (string, error) {
+ output, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to format JSON: %w", err)
+ }
+ return utils.ColorizeJSON(output), nil
+}
+
+func PrintJSONWithHeader(header string, data any) error {
+ fmt.Fprintf(os.Stderr, "\n%s\n", Bold(header))
+ output, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to format JSON: %w", err)
+ }
+ fmt.Println(utils.ColorizeJSON(output))
+ return nil
+}
diff --git a/internal/formatter/geo.go b/internal/formatter/geo.go
new file mode 100644
index 0000000..8b95899
--- /dev/null
+++ b/internal/formatter/geo.go
@@ -0,0 +1,10 @@
+package formatter
+
+import (
+ "git.db.org.ai/dborg/internal/models"
+ "git.db.org.ai/dborg/internal/utils"
+)
+
+func FormatGeoResults(response models.GeoSearchResponse, asJSON bool) error {
+ return utils.PrintJSON(response)
+}
diff --git a/internal/formatter/npd.go b/internal/formatter/npd.go
new file mode 100644
index 0000000..0d15d7b
--- /dev/null
+++ b/internal/formatter/npd.go
@@ -0,0 +1,139 @@
+package formatter
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "git.db.org.ai/dborg/internal/models"
+)
+
+func FormatNPDResults(resp *models.NPDResponse, asJSON bool) (string, error) {
+ if asJSON {
+ data, err := json.MarshalIndent(resp.Results.Hits, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return string(data), nil
+ }
+
+ var sb strings.Builder
+
+ sb.WriteString(fmt.Sprintf("\n%s\n", Bold(Cyan("NPD Breach Data Search Results"))))
+ sb.WriteString(fmt.Sprintf("%s\n\n", Gray(strings.Repeat("─", 50))))
+
+ sb.WriteString(fmt.Sprintf("%s %s / %s max\n",
+ Blue("Total Hits:"),
+ Yellow(fmt.Sprintf("%d", resp.Results.NumHits)),
+ Dim(fmt.Sprintf("%d", resp.MaxHits))))
+ sb.WriteString(fmt.Sprintf("%s %s\n\n",
+ Blue("Query Time:"),
+ Dim(fmt.Sprintf("%d microseconds", resp.Results.ElapsedTimeMicros))))
+
+ if len(resp.Results.Errors) > 0 {
+ sb.WriteString(fmt.Sprintf("%s\n", Red("Errors:")))
+ for _, err := range resp.Results.Errors {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", Red("✗"), err))
+ }
+ sb.WriteString("\n")
+ }
+
+ if len(resp.Results.Hits) > 0 {
+ sb.WriteString(fmt.Sprintf("%s\n", Bold("Records:")))
+ sb.WriteString(fmt.Sprintf("%s\n", Gray(strings.Repeat("─", 50))))
+
+ for i, hit := range resp.Results.Hits {
+ sb.WriteString(fmt.Sprintf("\n%s:\n", Bold(Blue(fmt.Sprintf("Record %d", i+1)))))
+
+ if name := getStringField(hit, "firstname", "lastname", "middlename"); name != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("Name:"), Bold(name)))
+ }
+
+ if dob := getStringField(hit, "dob"); dob != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("DOB:"), dob))
+ }
+
+ if ssn := getStringField(hit, "ssn"); ssn != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("SSN:"), Yellow(ssn)))
+ }
+
+ if phone := getStringField(hit, "phone1"); phone != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("Phone:"), phone))
+ }
+
+ if addr := buildAddress(hit); addr != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("Address:"), addr))
+ }
+
+ for k, v := range hit {
+ if !isCommonField(k) && v != nil && v != "" {
+ sb.WriteString(fmt.Sprintf(" %s %v\n", Dim(fmt.Sprintf("%s:", k)), v))
+ }
+ }
+ }
+ } else {
+ sb.WriteString(fmt.Sprintf("%s\n", Gray("No records found")))
+ }
+
+ sb.WriteString("\n")
+ if resp.Credits.Unlimited {
+ sb.WriteString(fmt.Sprintf("%s: %s\n", Dim("Credits"), Green("Unlimited")))
+ } else {
+ sb.WriteString(fmt.Sprintf("%s: %s\n", Dim("Credits Remaining"), FormatCredits(int64(resp.Credits.Remaining))))
+ }
+
+ return sb.String(), nil
+}
+
+func getStringField(data map[string]any, keys ...string) string {
+ var parts []string
+ for _, key := range keys {
+ if val, ok := data[key]; ok && val != nil {
+ if str, ok := val.(string); ok && str != "" {
+ parts = append(parts, str)
+ }
+ }
+ }
+ return strings.Join(parts, " ")
+}
+
+func buildAddress(data map[string]any) string {
+ var parts []string
+
+ if addr, ok := data["address"].(string); ok && addr != "" {
+ parts = append(parts, addr)
+ }
+
+ cityStateZip := []string{}
+ if city, ok := data["city"].(string); ok && city != "" {
+ cityStateZip = append(cityStateZip, city)
+ }
+ if state, ok := data["st"].(string); ok && state != "" {
+ cityStateZip = append(cityStateZip, state)
+ }
+ if zip, ok := data["zip"].(string); ok && zip != "" {
+ cityStateZip = append(cityStateZip, zip)
+ }
+
+ if len(cityStateZip) > 0 {
+ parts = append(parts, strings.Join(cityStateZip, ", "))
+ }
+
+ return strings.Join(parts, ", ")
+}
+
+func isCommonField(field string) bool {
+ commonFields := map[string]bool{
+ "firstname": true,
+ "lastname": true,
+ "middlename": true,
+ "dob": true,
+ "ssn": true,
+ "phone1": true,
+ "address": true,
+ "city": true,
+ "st": true,
+ "zip": true,
+ }
+ return commonFields[field]
+}
diff --git a/internal/formatter/reddit.go b/internal/formatter/reddit.go
new file mode 100644
index 0000000..a696deb
--- /dev/null
+++ b/internal/formatter/reddit.go
@@ -0,0 +1,444 @@
+package formatter
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "html"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "git.db.org.ai/dborg/internal/models"
+)
+
+func FormatRedditResults(resp interface{}, asJSON bool) (string, error) {
+ if asJSON {
+ data, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return string(data), nil
+ }
+
+ var credits models.RedditCredits
+ var resultsData interface{}
+ var dataType string
+ var identifier string
+
+ switch r := resp.(type) {
+ case *models.SubredditResponse:
+ credits = r.Credits
+ resultsData = r.Results
+ dataType = r.Type
+ identifier = r.Subreddit
+ case *models.UserResponse:
+ credits = r.Credits
+ resultsData = r.Results
+ dataType = r.Type
+ identifier = r.Username
+ default:
+ return "", fmt.Errorf("unsupported response type")
+ }
+
+ var buf bytes.Buffer
+
+ switch dataType {
+ case "comments":
+ formatRedditComments(&buf, resultsData, dataType, identifier)
+ case "posts":
+ formatRedditPosts(&buf, resultsData, dataType, identifier)
+ case "about":
+ formatRedditUserAbout(&buf, resultsData, dataType, identifier)
+ default:
+ buf.WriteString(fmt.Sprintf("\n%s\n", Bold(Cyan(fmt.Sprintf("Reddit %s - %s", dataType, identifier)))))
+ buf.WriteString(fmt.Sprintf("%s\n\n", Gray(strings.Repeat("─", 80))))
+
+ if resultsData != nil {
+ resultsJSON, err := json.MarshalIndent(resultsData, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal results: %w", err)
+ }
+ buf.WriteString(string(resultsJSON))
+ buf.WriteString("\n\n")
+ } else {
+ buf.WriteString(fmt.Sprintf("%s\n\n", Gray("No results found")))
+ }
+ }
+
+ if credits.Unlimited {
+ buf.WriteString(fmt.Sprintf("%s: %s\n", Dim("Credits"), Green("Unlimited")))
+ } else {
+ buf.WriteString(fmt.Sprintf("%s: %s\n", Dim("Credits Remaining"), FormatCredits(int64(credits.Remaining))))
+ }
+
+ return buf.String(), nil
+}
+
+func formatRedditUserAbout(buf *bytes.Buffer, resultsData interface{}, dataType, identifier string) {
+ fmt.Fprintf(buf, "\n%s\n", Bold(Cyan(fmt.Sprintf("Reddit User - u/%s", identifier))))
+ fmt.Fprintf(buf, "%s\n\n", Gray(strings.Repeat("─", 80)))
+
+ userData, ok := resultsData.(map[string]interface{})
+ if !ok {
+ fmt.Fprintf(buf, "%s\n", Gray("Invalid data structure"))
+ return
+ }
+
+ data, ok := userData["data"].(map[string]interface{})
+ if !ok {
+ fmt.Fprintf(buf, "%s\n", Gray("No user data found"))
+ return
+ }
+
+ w := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0)
+
+ if name := getStringValue(data, "name"); name != "" {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Username:"), Bold(fmt.Sprintf("u/%s", name)))
+ }
+
+ if id := getStringValue(data, "id"); id != "" {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("User ID:"), Dim(id))
+ }
+
+ if created, ok := data["created_utc"].(float64); ok {
+ timestamp := time.Unix(int64(created), 0)
+ age := time.Since(timestamp)
+ years := int(age.Hours() / 24 / 365)
+ fmt.Fprintf(w, "%s\t%s (%s)\n",
+ Cyan("Account Age:"),
+ Gray(timestamp.Format("2006-01-02")),
+ Yellow(fmt.Sprintf("%d years", years)))
+ }
+
+ totalKarma := getIntValue(data, "total_karma")
+ linkKarma := getIntValue(data, "link_karma")
+ commentKarma := getIntValue(data, "comment_karma")
+ awardeeKarma := getIntValue(data, "awardee_karma")
+ awarderKarma := getIntValue(data, "awarder_karma")
+
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Total Karma:"), Bold(Green(fmt.Sprintf("%d", totalKarma))))
+ fmt.Fprintf(w, "%s\t%s\n", Cyan(" Post Karma:"), Green(fmt.Sprintf("%d", linkKarma)))
+ fmt.Fprintf(w, "%s\t%s\n", Cyan(" Comment Karma:"), Green(fmt.Sprintf("%d", commentKarma)))
+ if awardeeKarma > 0 {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan(" Awardee Karma:"), Green(fmt.Sprintf("%d", awardeeKarma)))
+ }
+ if awarderKarma > 0 {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan(" Awarder Karma:"), Green(fmt.Sprintf("%d", awarderKarma)))
+ }
+
+ badges := []string{}
+ if isMod, ok := data["is_mod"].(bool); ok && isMod {
+ badges = append(badges, Green("Moderator"))
+ }
+ if isEmployee, ok := data["is_employee"].(bool); ok && isEmployee {
+ badges = append(badges, Red("Reddit Employee"))
+ }
+ if isGold, ok := data["is_gold"].(bool); ok && isGold {
+ badges = append(badges, Yellow("Premium"))
+ }
+ if verified, ok := data["verified"].(bool); ok && verified {
+ badges = append(badges, Blue("Verified Email"))
+ }
+
+ if len(badges) > 0 {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Badges:"), strings.Join(badges, " • "))
+ }
+
+ if acceptFollowers, ok := data["accept_followers"].(bool); ok {
+ if acceptFollowers {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Followers:"), Green("Enabled"))
+ } else {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Followers:"), Gray("Disabled"))
+ }
+ }
+
+ w.Flush()
+
+ if subreddit, ok := data["subreddit"].(map[string]interface{}); ok {
+ if publicDesc := getStringValue(subreddit, "public_description"); publicDesc != "" {
+ fmt.Fprintf(buf, "\n%s\n", Cyan("Bio:"))
+ publicDesc = html.UnescapeString(publicDesc)
+ lines := strings.Split(publicDesc, "\n")
+ for _, line := range lines {
+ if len(line) > 78 {
+ wrapped := wrapText(line, 76)
+ for _, wLine := range wrapped {
+ fmt.Fprintf(buf, " %s\n", wLine)
+ }
+ } else {
+ fmt.Fprintf(buf, " %s\n", line)
+ }
+ }
+ }
+ }
+
+ if iconImg := getStringValue(data, "icon_img"); iconImg != "" {
+ fmt.Fprintf(buf, "\n%s %s\n", Cyan("Avatar:"), Blue(iconImg))
+ }
+
+ fmt.Fprintf(buf, "\n")
+}
+
+func formatRedditPosts(buf *bytes.Buffer, resultsData interface{}, dataType, identifier string) {
+ fmt.Fprintf(buf, "\n%s\n", Bold(Cyan(fmt.Sprintf("Reddit %s - %s", dataType, identifier))))
+ fmt.Fprintf(buf, "%s\n\n", Gray(strings.Repeat("─", 80)))
+
+ listing, ok := resultsData.(map[string]interface{})
+ if !ok {
+ fmt.Fprintf(buf, "%s\n", Gray("Invalid data structure"))
+ return
+ }
+
+ data, ok := listing["data"].(map[string]interface{})
+ if !ok {
+ fmt.Fprintf(buf, "%s\n", Gray("No data found"))
+ return
+ }
+
+ children, ok := data["children"].([]interface{})
+ if !ok || len(children) == 0 {
+ fmt.Fprintf(buf, "%s\n", Gray("No posts found"))
+ return
+ }
+
+ for i, child := range children {
+ childMap, ok := child.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ postData, ok := childMap["data"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ formatSinglePost(buf, postData, i+1)
+ }
+}
+
+func formatRedditComments(buf *bytes.Buffer, resultsData interface{}, dataType, identifier string) {
+ fmt.Fprintf(buf, "\n%s\n", Bold(Cyan(fmt.Sprintf("Reddit %s - %s", dataType, identifier))))
+ fmt.Fprintf(buf, "%s\n\n", Gray(strings.Repeat("─", 80)))
+
+ listing, ok := resultsData.(map[string]interface{})
+ if !ok {
+ fmt.Fprintf(buf, "%s\n", Gray("Invalid data structure"))
+ return
+ }
+
+ data, ok := listing["data"].(map[string]interface{})
+ if !ok {
+ fmt.Fprintf(buf, "%s\n", Gray("No data found"))
+ return
+ }
+
+ children, ok := data["children"].([]interface{})
+ if !ok || len(children) == 0 {
+ fmt.Fprintf(buf, "%s\n", Gray("No comments found"))
+ return
+ }
+
+ for i, child := range children {
+ childMap, ok := child.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ commentData, ok := childMap["data"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ formatSingleComment(buf, commentData, i+1)
+ }
+}
+
+func formatSinglePost(buf *bytes.Buffer, post map[string]interface{}, num int) {
+ fmt.Fprintf(buf, "%s\n", Bold(Yellow(fmt.Sprintf("Post #%d", num))))
+
+ title := getStringValue(post, "title")
+ if title != "" {
+ title = html.UnescapeString(title)
+ fmt.Fprintf(buf, "%s\n", Bold(Green(title)))
+ }
+
+ w := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0)
+
+ author := getStringValue(post, "author")
+ subreddit := getStringValue(post, "subreddit")
+ if author != "" {
+ fmt.Fprintf(w, "%s\t%s", Cyan("Author:"), Bold(fmt.Sprintf("u/%s", author)))
+ if subreddit != "" {
+ fmt.Fprintf(w, " in %s", Bold(fmt.Sprintf("r/%s", subreddit)))
+ }
+ fmt.Fprintf(w, "\n")
+ }
+
+ if created, ok := post["created_utc"].(float64); ok {
+ timestamp := time.Unix(int64(created), 0)
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Posted:"), Gray(timestamp.Format("2006-01-02 15:04:05 MST")))
+ }
+
+ score := getIntValue(post, "score")
+ upvoteRatio := 0.0
+ if ratio, ok := post["upvote_ratio"].(float64); ok {
+ upvoteRatio = ratio
+ }
+ fmt.Fprintf(w, "%s\t%s", Cyan("Score:"), formatScore(score))
+ if upvoteRatio > 0 {
+ fmt.Fprintf(w, " (%s upvoted)", Dim(fmt.Sprintf("%.0f%%", upvoteRatio*100)))
+ }
+ fmt.Fprintf(w, "\n")
+
+ numComments := getIntValue(post, "num_comments")
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Comments:"), Yellow(fmt.Sprintf("%d", numComments)))
+
+ if stickied, ok := post["stickied"].(bool); ok && stickied {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Status:"), Green("Stickied"))
+ }
+
+ if linkFlair := getStringValue(post, "link_flair_text"); linkFlair != "" {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Flair:"), Magenta(linkFlair))
+ }
+
+ domain := getStringValue(post, "domain")
+ url := getStringValue(post, "url")
+ isSelf, _ := post["is_self"].(bool)
+
+ if !isSelf && url != "" {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Link:"), Blue(url))
+ if domain != "" && !strings.HasPrefix(domain, "self.") {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Domain:"), Dim(domain))
+ }
+ }
+
+ if permalink := getStringValue(post, "permalink"); permalink != "" {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Reddit:"), Blue(fmt.Sprintf("https://reddit.com%s", permalink)))
+ }
+
+ w.Flush()
+
+ selftext := getStringValue(post, "selftext")
+ if selftext != "" && selftext != "[removed]" && selftext != "[deleted]" {
+ fmt.Fprintf(buf, "\n%s\n", Cyan("Content:"))
+ selftext = html.UnescapeString(selftext)
+
+ lines := strings.Split(selftext, "\n")
+ displayedLines := 0
+ maxLines := 15
+
+ for _, line := range lines {
+ if displayedLines >= maxLines {
+ remaining := len(lines) - displayedLines
+ fmt.Fprintf(buf, " %s\n", Dim(fmt.Sprintf("... (%d more lines)", remaining)))
+ break
+ }
+
+ if len(line) > 80 {
+ wrapped := wrapText(line, 78)
+ for _, wLine := range wrapped {
+ if displayedLines >= maxLines {
+ break
+ }
+ fmt.Fprintf(buf, " %s\n", wLine)
+ displayedLines++
+ }
+ } else {
+ fmt.Fprintf(buf, " %s\n", line)
+ displayedLines++
+ }
+ }
+ }
+
+ if edited := post["edited"]; edited != nil && edited != false {
+ fmt.Fprintf(buf, "\n%s\n", Dim("(edited)"))
+ }
+
+ fmt.Fprintf(buf, "\n%s\n\n", Gray(strings.Repeat("─", 80)))
+}
+
+func formatSingleComment(buf *bytes.Buffer, comment map[string]interface{}, num int) {
+ w := tabwriter.NewWriter(buf, 0, 0, 1, ' ', 0)
+
+ fmt.Fprintf(buf, "%s\n", Bold(Yellow(fmt.Sprintf("Comment #%d", num))))
+
+ author := getStringValue(comment, "author")
+ if author != "" {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Author:"), Bold(author))
+ }
+
+ subreddit := getStringValue(comment, "subreddit")
+ if subreddit != "" {
+ fmt.Fprintf(w, "%s\tr/%s\n", Cyan("Subreddit:"), subreddit)
+ }
+
+ if created, ok := comment["created_utc"].(float64); ok {
+ timestamp := time.Unix(int64(created), 0)
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Posted:"), Gray(timestamp.Format("2006-01-02 15:04:05 MST")))
+ }
+
+ score := getIntValue(comment, "score")
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Score:"), formatScore(score))
+
+ if controversiality, ok := comment["controversiality"].(float64); ok && controversiality > 0 {
+ fmt.Fprintf(w, "%s\t%s\n", Cyan("Controversial:"), Yellow("Yes"))
+ }
+
+ w.Flush()
+
+ if linkTitle := getStringValue(comment, "link_title"); linkTitle != "" {
+ fmt.Fprintf(buf, "%s %s\n", Cyan("Post:"), Bold(linkTitle))
+ }
+
+ if permalink := getStringValue(comment, "permalink"); permalink != "" {
+ fmt.Fprintf(buf, "%s %s\n", Cyan("Link:"), Blue(fmt.Sprintf("https://reddit.com%s", permalink)))
+ }
+
+ body := getStringValue(comment, "body")
+ if body != "" {
+ fmt.Fprintf(buf, "\n%s\n", Cyan("Comment:"))
+ body = html.UnescapeString(body)
+
+ lines := strings.Split(body, "\n")
+ for _, line := range lines {
+ if len(line) > 80 {
+ wrapped := wrapText(line, 78)
+ for _, wLine := range wrapped {
+ fmt.Fprintf(buf, " %s\n", Green(wLine))
+ }
+ } else {
+ fmt.Fprintf(buf, " %s\n", Green(line))
+ }
+ }
+ }
+
+ if edited := comment["edited"]; edited != nil && edited != false {
+ fmt.Fprintf(buf, "\n%s\n", Dim("(edited)"))
+ }
+
+ fmt.Fprintf(buf, "\n%s\n\n", Gray(strings.Repeat("─", 80)))
+}
+
+func getStringValue(data map[string]interface{}, key string) string {
+ if val, ok := data[key].(string); ok {
+ return val
+ }
+ return ""
+}
+
+func getIntValue(data map[string]interface{}, key string) int {
+ if val, ok := data[key].(float64); ok {
+ return int(val)
+ }
+ return 0
+}
+
+func formatScore(score int) string {
+ if score > 0 {
+ return Green(fmt.Sprintf("+%d", score))
+ } else if score < 0 {
+ return Red(fmt.Sprintf("%d", score))
+ }
+ return Dim("0")
+}
diff --git a/internal/formatter/shortlinks.go b/internal/formatter/shortlinks.go
new file mode 100644
index 0000000..d6073cb
--- /dev/null
+++ b/internal/formatter/shortlinks.go
@@ -0,0 +1,80 @@
+package formatter
+
+import (
+ "fmt"
+ "strings"
+
+ "git.db.org.ai/dborg/internal/models"
+ "git.db.org.ai/dborg/internal/utils"
+)
+
+func FormatShortlinksResults(response *models.ShortlinksSearchResponse, asJSON bool) error {
+ if asJSON {
+ return utils.PrintJSON(response)
+ }
+
+ if response.Results == nil {
+ PrintWarning("No results found")
+ return nil
+ }
+
+ resultsMap, ok := response.Results.(map[string]interface{})
+ if !ok {
+ return utils.PrintJSON(response)
+ }
+
+ hits, ok := resultsMap["hits"].([]interface{})
+ if !ok || len(hits) == 0 {
+ PrintWarning("No results found")
+ return nil
+ }
+
+ numHits, _ := resultsMap["num_hits"].(float64)
+ elapsed, _ := resultsMap["elapsed_time_micros"].(float64)
+
+ fmt.Printf("%s %s %s\n",
+ Cyan("Found"),
+ Bold(Yellow(fmt.Sprintf("%d", int(numHits)))),
+ Cyan(fmt.Sprintf("credentials (%.2fms)", elapsed/1000)))
+ fmt.Println()
+
+ for i, hit := range hits {
+ hitMap, ok := hit.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ if i > 0 {
+ fmt.Println(Dim(strings.Repeat("─", 80)))
+ }
+
+ username, _ := hitMap["username"].(string)
+ password, _ := hitMap["password"].(string)
+ url, _ := hitMap["url"].(string)
+ filename, _ := hitMap["filename"].(string)
+
+ fmt.Printf("%s %s\n", Bold(Green("●")), Bold(username))
+
+ if password != "" {
+ fmt.Printf(" %s %s\n", Dim("Password:"), Yellow(password))
+ }
+
+ if url != "" {
+ fmt.Printf(" %s %s\n", Dim("URL:"), Blue(url))
+ }
+
+ if filename != "" {
+ fmt.Printf(" %s %s\n", Dim("Source:"), Magenta(filename))
+ }
+
+ fmt.Println()
+ }
+
+ if response.Credits.Unlimited {
+ fmt.Printf("%s: %s\n", Dim("Credits"), Green("Unlimited"))
+ } else {
+ fmt.Printf("%s: %s\n", Dim("Credits Remaining"), FormatCredits(int64(response.Credits.Remaining)))
+ }
+
+ return nil
+}
diff --git a/internal/formatter/skiptrace.go b/internal/formatter/skiptrace.go
new file mode 100644
index 0000000..26c0a0f
--- /dev/null
+++ b/internal/formatter/skiptrace.go
@@ -0,0 +1,128 @@
+package formatter
+
+import (
+ "fmt"
+ "strings"
+
+ "git.db.org.ai/dborg/internal/models"
+)
+
+func FormatSkiptraceResults(resp interface{}, asJSON bool) (string, error) {
+ var data map[string]interface{}
+
+ switch r := resp.(type) {
+ case *models.SkiptraceResponse:
+ data = r.Data
+ case *models.SkiptraceReportResponse:
+ data = r.Data
+ case *models.SkiptracePhoneResponse:
+ data = r.Data
+ case *models.SkiptraceEmailResponse:
+ data = r.Data
+ default:
+ return "", fmt.Errorf("unsupported skiptrace response type")
+ }
+
+ err := PrintColorizedJSON(data)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return "", nil
+}
+
+func formatSkiptraceData(sb *strings.Builder, data map[string]interface{}) {
+ if person, ok := data["person"].(map[string]interface{}); ok {
+ sb.WriteString(fmt.Sprintf("%s\n", Bold("Person Information:")))
+ formatPersonInfo(sb, person)
+ delete(data, "person")
+ }
+
+ if phones, ok := data["phones"].([]interface{}); ok && len(phones) > 0 {
+ sb.WriteString(fmt.Sprintf("\n%s\n", Bold("Phone Numbers:")))
+ for _, phone := range phones {
+ if phoneMap, ok := phone.(map[string]interface{}); ok {
+ if number, ok := phoneMap["number"].(string); ok {
+ sb.WriteString(fmt.Sprintf(" %s %s", Dim("•"), Cyan(number)))
+ if carrier, ok := phoneMap["carrier"].(string); ok && carrier != "" {
+ sb.WriteString(fmt.Sprintf(" %s", Gray(fmt.Sprintf("(%s)", carrier))))
+ }
+ if phoneType, ok := phoneMap["type"].(string); ok && phoneType != "" {
+ sb.WriteString(fmt.Sprintf(" %s", Dim(phoneType)))
+ }
+ sb.WriteString("\n")
+ }
+ }
+ }
+ delete(data, "phones")
+ }
+
+ if emails, ok := data["emails"].([]interface{}); ok && len(emails) > 0 {
+ sb.WriteString(fmt.Sprintf("\n%s\n", Bold("Email Addresses:")))
+ for _, email := range emails {
+ if emailStr, ok := email.(string); ok {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", Dim("•"), Blue(emailStr)))
+ }
+ }
+ delete(data, "emails")
+ }
+
+ if addresses, ok := data["addresses"].([]interface{}); ok && len(addresses) > 0 {
+ sb.WriteString(fmt.Sprintf("\n%s\n", Bold("Addresses:")))
+ for _, addr := range addresses {
+ if addrMap, ok := addr.(map[string]interface{}); ok {
+ formatAddress(sb, addrMap)
+ }
+ }
+ delete(data, "addresses")
+ }
+
+ if len(data) > 0 {
+ sb.WriteString(fmt.Sprintf("\n%s\n", Bold("Additional Information:")))
+ if colorized, err := FormatColorizedJSON(data); err == nil {
+ lines := strings.Split(colorized, "\n")
+ for _, line := range lines {
+ sb.WriteString(fmt.Sprintf(" %s\n", line))
+ }
+ }
+ }
+}
+
+func formatPersonInfo(sb *strings.Builder, person map[string]interface{}) {
+ if name, ok := person["name"].(string); ok && name != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("Name:"), Bold(name)))
+ }
+ if age, ok := person["age"].(float64); ok && age > 0 {
+ sb.WriteString(fmt.Sprintf(" %s %d\n", Cyan("Age:"), int(age)))
+ }
+ if dob, ok := person["dob"].(string); ok && dob != "" {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("DOB:"), dob))
+ }
+}
+
+func formatAddress(sb *strings.Builder, addr map[string]interface{}) {
+ var addrStr strings.Builder
+ if street, ok := addr["street"].(string); ok && street != "" {
+ addrStr.WriteString(street)
+ }
+ if city, ok := addr["city"].(string); ok && city != "" {
+ if addrStr.Len() > 0 {
+ addrStr.WriteString(", ")
+ }
+ addrStr.WriteString(city)
+ }
+ if state, ok := addr["state"].(string); ok && state != "" {
+ if addrStr.Len() > 0 {
+ addrStr.WriteString(", ")
+ }
+ addrStr.WriteString(state)
+ }
+ if zip, ok := addr["zip"].(string); ok && zip != "" {
+ if addrStr.Len() > 0 {
+ addrStr.WriteString(" ")
+ }
+ addrStr.WriteString(zip)
+ }
+ if addrStr.Len() > 0 {
+ sb.WriteString(fmt.Sprintf(" %s %s\n", Dim("•"), addrStr.String()))
+ }
+}
diff --git a/internal/formatter/sl.go b/internal/formatter/sl.go
new file mode 100644
index 0000000..d7d293f
--- /dev/null
+++ b/internal/formatter/sl.go
@@ -0,0 +1,125 @@
+package formatter
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "git.db.org.ai/dborg/internal/models"
+)
+
+func FormatSLResults(resp *models.SLResponse, asJSON bool) (string, error) {
+ if asJSON {
+ data, err := json.MarshalIndent(resp.Results, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return string(data), nil
+ }
+
+ var sb strings.Builder
+
+ if resp.Results == nil {
+ sb.WriteString(fmt.Sprintf("\n%s\n\n", Gray("No results found")))
+ return sb.String(), nil
+ }
+
+ resultsMap, ok := resp.Results.(map[string]interface{})
+ if !ok {
+ resultsJSON, err := json.MarshalIndent(resp.Results, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to format results: %w", err)
+ }
+ return string(resultsJSON), nil
+ }
+
+ hits, ok := resultsMap["hits"].([]interface{})
+ if !ok || len(hits) == 0 {
+ sb.WriteString(fmt.Sprintf("\n%s\n\n", Gray("No results found")))
+ return sb.String(), nil
+ }
+
+ numHits, _ := resultsMap["num_hits"].(float64)
+ elapsed, _ := resultsMap["elapsed_time_micros"].(float64)
+
+ sb.WriteString(fmt.Sprintf("%s %s %s\n",
+ Cyan("Found"),
+ Bold(Yellow(fmt.Sprintf("%d", int(numHits)))),
+ Cyan(fmt.Sprintf("credentials (%.2fms)", elapsed/1000))))
+ sb.WriteString("\n")
+
+ for i, hit := range hits {
+ hitMap, ok := hit.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ if i > 0 {
+ sb.WriteString(Dim("───"))
+ sb.WriteString("\n")
+ }
+
+ username, _ := hitMap["username"].(string)
+ password, _ := hitMap["password"].(string)
+ url, _ := hitMap["url"].(string)
+ filename, _ := hitMap["filename"].(string)
+
+ sb.WriteString(fmt.Sprintf("%s %s\n", Dim("Username:"), Bold(username)))
+
+ if password != "" {
+ sb.WriteString(fmt.Sprintf("%s %s\n", Dim("Password:"), Yellow(password)))
+ }
+
+ if url != "" {
+ sb.WriteString(fmt.Sprintf("%s %s\n", Dim("URL:"), Blue(url)))
+ }
+
+ if filename != "" {
+ sb.WriteString(fmt.Sprintf("%s %s\n", Dim("Source:"), Magenta(filename)))
+ }
+
+ sb.WriteString("\n")
+ }
+
+ if resp.Credits.Unlimited {
+ sb.WriteString(fmt.Sprintf("%s: %s\n", Dim("Credits"), Green("Unlimited")))
+ } else {
+ sb.WriteString(fmt.Sprintf("%s: %s\n", Dim("Credits Remaining"), FormatCredits(int64(resp.Credits.Remaining))))
+ }
+
+ return sb.String(), nil
+}
+
+func formatSLResultsData(sb *strings.Builder, data map[string]interface{}) {
+ sb.WriteString(fmt.Sprintf("%s\n", Bold("Stealer Log Entries:")))
+
+ for key, value := range data {
+ sb.WriteString(fmt.Sprintf("\n %s:\n", Cyan(key)))
+ if valueMap, ok := value.(map[string]interface{}); ok {
+ for k, v := range valueMap {
+ sb.WriteString(fmt.Sprintf(" %s %v\n", Dim(fmt.Sprintf("%s:", k)), v))
+ }
+ } else if valueArray, ok := value.([]interface{}); ok {
+ for i, item := range valueArray {
+ sb.WriteString(fmt.Sprintf(" %s %v\n", Dim(fmt.Sprintf("[%d]:", i)), item))
+ }
+ } else {
+ sb.WriteString(fmt.Sprintf(" %v\n", value))
+ }
+ }
+}
+
+func formatSLResultsArray(sb *strings.Builder, data []interface{}) {
+ sb.WriteString(fmt.Sprintf("%s\n", Bold("Stealer Log Entries:")))
+
+ for i, entry := range data {
+ sb.WriteString(fmt.Sprintf("\n %s:\n", Blue(fmt.Sprintf("Entry %d", i+1))))
+ if entryMap, ok := entry.(map[string]interface{}); ok {
+ for key, value := range entryMap {
+ sb.WriteString(fmt.Sprintf(" %s %v\n", Dim(fmt.Sprintf("%s:", key)), value))
+ }
+ } else {
+ sb.WriteString(fmt.Sprintf(" %v\n", entry))
+ }
+ }
+}
diff --git a/internal/formatter/username.go b/internal/formatter/username.go
new file mode 100644
index 0000000..fc25487
--- /dev/null
+++ b/internal/formatter/username.go
@@ -0,0 +1,143 @@
+package formatter
+
+import (
+ "fmt"
+ "strings"
+
+ "git.db.org.ai/dborg/internal/models"
+ "git.db.org.ai/dborg/internal/utils"
+)
+
+func FormatUsernameSiteResult(result *models.SiteResult) error {
+ if result.Status == "found" {
+ fmt.Printf("%s | %s",
+ Green("✓ FOUND"),
+ result.SiteName)
+
+ if result.URL != "" {
+ fmt.Printf(" | %s", Blue(result.URL))
+ }
+
+ fmt.Println()
+
+ if len(result.Metadata) > 0 {
+ fmt.Print(formatMetadata(result.Metadata))
+ }
+ }
+ return nil
+}
+
+func FormatUsernameResults(response *models.USRSXResponse, asJSON bool) error {
+ if asJSON {
+ return utils.PrintJSON(response)
+ }
+
+ if response.Error != "" {
+ return fmt.Errorf("%s", response.Error)
+ }
+
+ if response.Message != "" {
+ return nil
+ }
+
+ if len(response.Results) == 0 {
+ return nil
+ }
+
+ for _, result := range response.Results {
+ if result.Status == "found" {
+ fmt.Printf("%s | %s",
+ Green("✓ FOUND"),
+ result.SiteName)
+
+ if result.URL != "" {
+ fmt.Printf(" | %s", Blue(result.URL))
+ }
+
+ fmt.Println()
+
+ if len(result.Metadata) > 0 {
+ fmt.Print(formatMetadata(result.Metadata))
+ }
+ }
+ }
+
+ return nil
+}
+
+func formatMetadata(metadata map[string]interface{}) string {
+ var lines []string
+ boxStyle := Dim(" ├─ ")
+ labelStyle := Dim
+
+ if displayName, ok := metadata["display_name"].(string); ok && displayName != "" {
+ lines = append(lines, boxStyle+labelStyle("Name: ")+displayName)
+ }
+
+ if bio, ok := metadata["bio"].(string); ok && bio != "" {
+ bioPreview := bio
+ if len(bioPreview) > 100 {
+ bioPreview = bioPreview[:97] + "..."
+ }
+ lines = append(lines, boxStyle+labelStyle("Bio: ")+bioPreview)
+ }
+
+ if avatar, ok := metadata["avatar_url"].(string); ok && avatar != "" {
+ lines = append(lines, boxStyle+labelStyle("Avatar: ")+avatar)
+ }
+
+ if location, ok := metadata["location"].(string); ok && location != "" {
+ lines = append(lines, boxStyle+labelStyle("Location: ")+location)
+ }
+
+ if website, ok := metadata["website"].(string); ok && website != "" {
+ lines = append(lines, boxStyle+labelStyle("Website: ")+website)
+ }
+
+ if joinDate, ok := metadata["join_date"].(string); ok && joinDate != "" {
+ lines = append(lines, boxStyle+labelStyle("Joined: ")+joinDate)
+ }
+
+ if followers, ok := metadata["follower_count"].(float64); ok && followers > 0 {
+ lines = append(lines, boxStyle+labelStyle("Followers: ")+fmt.Sprintf("%d", int(followers)))
+ }
+
+ if following, ok := metadata["following_count"].(float64); ok && following > 0 {
+ lines = append(lines, boxStyle+labelStyle("Following: ")+fmt.Sprintf("%d", int(following)))
+ }
+
+ if verified, ok := metadata["is_verified"].(bool); ok && verified {
+ lines = append(lines, boxStyle+labelStyle("Verified: ")+Green("✓ Yes"))
+ }
+
+ if additionalLinks, ok := metadata["additional_links"].(map[string]interface{}); ok && len(additionalLinks) > 0 {
+ linkCount := 0
+ linkLines := []string{}
+ for key, value := range additionalLinks {
+ if strVal, ok := value.(string); ok {
+ linkLines = append(linkLines, Dim(" ├─ ")+labelStyle(key+": ")+strVal)
+ linkCount++
+ }
+ }
+ if linkCount > 0 {
+ lastBoxStyle := Dim(" └─ ")
+ lines = append(lines, lastBoxStyle+labelStyle("Links:"))
+ for i, linkLine := range linkLines {
+ if i == linkCount-1 {
+ linkLine = strings.Replace(linkLine, "├─", "└─", 1)
+ }
+ lines = append(lines, linkLine)
+ }
+ }
+ } else {
+ if len(lines) > 0 {
+ lastIdx := len(lines) - 1
+ lines[lastIdx] = strings.Replace(lines[lastIdx], "├─", "└─", 1)
+ }
+ }
+
+ if len(lines) > 0 {
+ return strings.Join(lines, "\n") + "\n"
+ }
+ return ""
+}
diff --git a/internal/formatter/x.go b/internal/formatter/x.go
new file mode 100644
index 0000000..f701797
--- /dev/null
+++ b/internal/formatter/x.go
@@ -0,0 +1,379 @@
+package formatter
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "text/tabwriter"
+
+ "git.db.org.ai/dborg/internal/models"
+)
+
+func FormatXHistory(resp *models.XResponse, asJSON bool) (string, error) {
+ if asJSON {
+ data, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return string(data), nil
+ }
+
+ var buf bytes.Buffer
+
+ if resp.Error != "" {
+ fmt.Fprintf(&buf, "%s\n", Red(fmt.Sprintf("Error: %s", resp.Error)))
+ return buf.String(), nil
+ }
+
+ fmt.Fprintf(&buf, "%s\n\n", Bold(Cyan(fmt.Sprintf("Username History for @%s", resp.Username))))
+
+ if len(resp.PreviousUsernames) == 0 {
+ fmt.Fprintf(&buf, "%s\n", Gray("No previous usernames found"))
+ } else {
+ w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0)
+ fmt.Fprintf(w, "%s\t%s\t%s\n", Bold("#"), Bold("Username"), Bold("Time Ago"))
+
+ for i, u := range resp.PreviousUsernames {
+ fmt.Fprintf(w, "%s\t%s\t%s\n",
+ Yellow(fmt.Sprintf("%d", i+1)),
+ Cyan(fmt.Sprintf("@%s", u.Username)),
+ Gray(u.TimeAgo))
+ }
+ w.Flush()
+ }
+
+ fmt.Fprintf(&buf, "\n%s ", Blue("Credits Remaining:"))
+ if resp.Credits.Unlimited {
+ fmt.Fprintf(&buf, "%s\n", Green("Unlimited"))
+ } else {
+ fmt.Fprintf(&buf, "%s\n", FormatCredits(int64(resp.Credits.Remaining)))
+ }
+
+ return buf.String(), nil
+}
+
+func FormatXTweets(streamResp *models.TweetsStreamResponse, asJSON bool) (string, error) {
+ if asJSON {
+ data, err := json.MarshalIndent(streamResp, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return string(data), nil
+ }
+
+ var buf bytes.Buffer
+
+ if streamResp.Error != "" {
+ fmt.Fprintf(&buf, "%s\n", Red(fmt.Sprintf("Error: %s", streamResp.Error)))
+ return buf.String(), nil
+ }
+
+ if streamResp.Tweet != nil {
+ tweet := streamResp.Tweet
+
+ if tweet.Name != "" {
+ fmt.Fprintf(&buf, "%s ", Cyan(tweet.Name))
+ }
+ if tweet.Handle != "" {
+ fmt.Fprintf(&buf, "%s", Gray(fmt.Sprintf("(@%s)", tweet.Handle)))
+ }
+ if tweet.Name != "" || tweet.Handle != "" {
+ fmt.Fprintf(&buf, "\n")
+ }
+
+ if tweet.Text != "" {
+ fmt.Fprintf(&buf, "%s\n", Green(tweet.Text))
+ }
+
+ if tweet.Type != "" {
+ fmt.Fprintf(&buf, "%s %s\n", Dim("Type:"), tweet.Type)
+ }
+
+ if tweet.URL != "" {
+ fmt.Fprintf(&buf, "%s\n", Blue(tweet.URL))
+ }
+
+ fmt.Fprintf(&buf, "%s\n", Dim("───"))
+ }
+
+ if streamResp.Progress != nil {
+ fmt.Fprintf(&buf, "%s %s/%s\n",
+ Dim("Progress:"),
+ Yellow(fmt.Sprintf("%d", streamResp.Progress.Current)),
+ Yellow(fmt.Sprintf("%d", streamResp.Progress.Total)))
+ }
+
+ if streamResp.Complete != nil {
+ fmt.Fprintf(&buf, "\n%s\n", Bold(Green("Complete!")))
+ fmt.Fprintf(&buf, "%s %s\n", Dim("Duration:"), streamResp.Complete.Duration)
+ fmt.Fprintf(&buf, "%s %s\n",
+ Dim("Total Fetched:"),
+ Green(fmt.Sprintf("%d", streamResp.Complete.TotalFetched)))
+ if streamResp.Complete.TotalFailed > 0 {
+ fmt.Fprintf(&buf, "%s %s\n",
+ Dim("Total Failed:"),
+ Yellow(fmt.Sprintf("%d", streamResp.Complete.TotalFailed)))
+ }
+ }
+
+ return buf.String(), nil
+}
+
+func FormatXFirstFollowers(resp *models.FirstFollowersResponse, asJSON bool) (string, error) {
+ if asJSON {
+ data, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return string(data), nil
+ }
+
+ var buf bytes.Buffer
+
+ fmt.Fprintf(&buf, "%s\n\n", Bold(Cyan(fmt.Sprintf("First 20 Followers of @%s", resp.Username))))
+
+ if len(resp.Followers) == 0 {
+ fmt.Fprintf(&buf, "%s\n", Gray("No followers found"))
+ } else {
+ w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0)
+ fmt.Fprintf(w, "%s\t%s\t%s\n", Bold("#"), Bold("Username"), Bold("Name"))
+
+ for _, f := range resp.Followers {
+ fmt.Fprintf(w, "%s\t%s\t%s\n",
+ Yellow(fmt.Sprintf("%d", f.Number)),
+ Cyan(fmt.Sprintf("@%s", f.Username)),
+ Green(f.Name))
+ }
+ w.Flush()
+ }
+
+ fmt.Fprintf(&buf, "\n%s ", Blue("Credits Remaining:"))
+ if resp.Credits.Unlimited {
+ fmt.Fprintf(&buf, "%s\n", Green("Unlimited"))
+ } else {
+ fmt.Fprintf(&buf, "%s\n", FormatCredits(int64(resp.Credits.Remaining)))
+ }
+
+ return buf.String(), nil
+}
+
+func FormatXNotableFollowers(resp *models.NotableFollowersResponse, asJSON bool) (string, error) {
+ if asJSON {
+ data, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return string(data), nil
+ }
+
+ var buf bytes.Buffer
+
+ fmt.Fprintf(&buf, "%s\n\n", Bold(Cyan(fmt.Sprintf("Notable Followers of @%s", resp.Username))))
+
+ if len(resp.Followers) == 0 {
+ fmt.Fprintf(&buf, "%s\n", Gray("No notable followers found"))
+ } else {
+ w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0)
+ fmt.Fprintf(w, "%s\t%s\t%s\n", Bold("Username"), Bold("Followers"), Bold("Score"))
+
+ for _, f := range resp.Followers {
+ fmt.Fprintf(w, "%s\t%s\t%s\n",
+ Cyan(fmt.Sprintf("@%s", f.Username)),
+ Green(f.FollowerCount),
+ Magenta(fmt.Sprintf("%.2f", f.Score)))
+ }
+ w.Flush()
+ }
+
+ fmt.Fprintf(&buf, "\n%s ", Blue("Credits Remaining:"))
+ if resp.Credits.Unlimited {
+ fmt.Fprintf(&buf, "%s\n", Green("Unlimited"))
+ } else {
+ fmt.Fprintf(&buf, "%s\n", FormatCredits(int64(resp.Credits.Remaining)))
+ }
+
+ return buf.String(), nil
+}
+
+func FormatXReplies(streamResp *models.TweetsStreamResponse, asJSON bool) (string, error) {
+ if asJSON {
+ data, err := json.MarshalIndent(streamResp, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return string(data), nil
+ }
+
+ var buf bytes.Buffer
+
+ if streamResp.Error != "" {
+ fmt.Fprintf(&buf, "%s\n", Red(fmt.Sprintf("Error: %s", streamResp.Error)))
+ return buf.String(), nil
+ }
+
+ if streamResp.Tweet != nil {
+ tweet := streamResp.Tweet
+
+ if tweet.Name != "" {
+ fmt.Fprintf(&buf, "%s ", Cyan(tweet.Name))
+ }
+ if tweet.Handle != "" {
+ fmt.Fprintf(&buf, "%s", Gray(fmt.Sprintf("(@%s)", tweet.Handle)))
+ }
+ if tweet.Name != "" || tweet.Handle != "" {
+ fmt.Fprintf(&buf, "\n")
+ }
+
+ if tweet.Text != "" {
+ lines := strings.Split(tweet.Text, "\n")
+ for _, line := range lines {
+ if len(line) > 80 {
+ wrapped := wrapText(line, 80)
+ for _, wLine := range wrapped {
+ fmt.Fprintf(&buf, "%s\n", Green(wLine))
+ }
+ } else {
+ fmt.Fprintf(&buf, "%s\n", Green(line))
+ }
+ }
+ }
+
+ if tweet.URL != "" {
+ fmt.Fprintf(&buf, "%s\n", Blue(tweet.URL))
+ }
+
+ fmt.Fprintf(&buf, "%s\n", Dim("───"))
+ }
+
+ if streamResp.Progress != nil {
+ fmt.Fprintf(&buf, "%s %s/%s\n",
+ Dim("Progress:"),
+ Yellow(fmt.Sprintf("%d", streamResp.Progress.Current)),
+ Yellow(fmt.Sprintf("%d", streamResp.Progress.Total)))
+ }
+
+ if streamResp.Complete != nil {
+ fmt.Fprintf(&buf, "\n%s\n", Bold(Green("Complete!")))
+ fmt.Fprintf(&buf, "%s %s\n", Dim("Duration:"), streamResp.Complete.Duration)
+ fmt.Fprintf(&buf, "%s %s\n",
+ Dim("Total Replies:"),
+ Green(fmt.Sprintf("%d", streamResp.Complete.TotalFetched)))
+ if streamResp.Complete.TotalFailed > 0 {
+ fmt.Fprintf(&buf, "%s %s\n",
+ Dim("Total Failed:"),
+ Yellow(fmt.Sprintf("%d", streamResp.Complete.TotalFailed)))
+ }
+ }
+
+ return buf.String(), nil
+}
+
+func FormatXSearch(streamResp *models.TweetsStreamResponse, asJSON bool) (string, error) {
+ if asJSON {
+ data, err := json.MarshalIndent(streamResp, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JSON: %w", err)
+ }
+ return string(data), nil
+ }
+
+ var buf bytes.Buffer
+
+ if streamResp.Error != "" {
+ fmt.Fprintf(&buf, "%s\n", Red(fmt.Sprintf("Error: %s", streamResp.Error)))
+ return buf.String(), nil
+ }
+
+ if streamResp.Tweet != nil {
+ tweet := streamResp.Tweet
+
+ if tweet.Name != "" {
+ fmt.Fprintf(&buf, "%s ", Cyan(tweet.Name))
+ }
+ if tweet.Handle != "" {
+ fmt.Fprintf(&buf, "%s", Gray(fmt.Sprintf("(@%s)", tweet.Handle)))
+ }
+ if tweet.Name != "" || tweet.Handle != "" {
+ fmt.Fprintf(&buf, "\n")
+ }
+
+ if tweet.Text != "" {
+ lines := strings.Split(tweet.Text, "\n")
+ for _, line := range lines {
+ if len(line) > 80 {
+ wrapped := wrapText(line, 80)
+ for _, wLine := range wrapped {
+ fmt.Fprintf(&buf, "%s\n", Green(wLine))
+ }
+ } else {
+ fmt.Fprintf(&buf, "%s\n", Green(line))
+ }
+ }
+ }
+
+ if tweet.Type != "" {
+ fmt.Fprintf(&buf, "%s %s\n", Dim("Type:"), tweet.Type)
+ }
+
+ if tweet.URL != "" {
+ fmt.Fprintf(&buf, "%s\n", Blue(tweet.URL))
+ }
+
+ fmt.Fprintf(&buf, "%s\n", Dim("───"))
+ }
+
+ if streamResp.Progress != nil {
+ fmt.Fprintf(&buf, "%s %s/%s\n",
+ Dim("Progress:"),
+ Yellow(fmt.Sprintf("%d", streamResp.Progress.Current)),
+ Yellow(fmt.Sprintf("%d", streamResp.Progress.Total)))
+ }
+
+ if streamResp.Complete != nil {
+ fmt.Fprintf(&buf, "\n%s\n", Bold(Green("Search Complete!")))
+ fmt.Fprintf(&buf, "%s %s\n", Dim("Duration:"), streamResp.Complete.Duration)
+ fmt.Fprintf(&buf, "%s %s\n",
+ Dim("Total Results:"),
+ Green(fmt.Sprintf("%d", streamResp.Complete.TotalFetched)))
+ if streamResp.Complete.TotalFailed > 0 {
+ fmt.Fprintf(&buf, "%s %s\n",
+ Dim("Total Failed:"),
+ Yellow(fmt.Sprintf("%d", streamResp.Complete.TotalFailed)))
+ }
+ }
+
+ return buf.String(), nil
+}
+
+func wrapText(text string, width int) []string {
+ if len(text) <= width {
+ return []string{text}
+ }
+
+ var lines []string
+ words := strings.Fields(text)
+ if len(words) == 0 {
+ return []string{text}
+ }
+
+ var currentLine strings.Builder
+ for _, word := range words {
+ if currentLine.Len() == 0 {
+ currentLine.WriteString(word)
+ } else if currentLine.Len()+1+len(word) <= width {
+ currentLine.WriteString(" ")
+ currentLine.WriteString(word)
+ } else {
+ lines = append(lines, currentLine.String())
+ currentLine.Reset()
+ currentLine.WriteString(word)
+ }
+ }
+
+ if currentLine.Len() > 0 {
+ lines = append(lines, currentLine.String())
+ }
+
+ return lines
+}
diff --git a/internal/utils/output.go b/internal/utils/output.go
index e8e97bc..6d7ad79 100644
--- a/internal/utils/output.go
+++ b/internal/utils/output.go
@@ -11,14 +11,14 @@ import (
const (
colorReset = "\033[0m"
colorKey = "\033[36m"
- colorString = "\033[32m"
+ colorString = "\033[93m"
colorNumber = "\033[33m"
colorBool = "\033[35m"
colorNull = "\033[90m"
colorBrace = "\033[37m"
)
-func colorizeJSON(data []byte) string {
+func ColorizeJSON(data []byte) string {
if !isTerminal() {
return string(data)
}
@@ -123,7 +123,7 @@ func PrintJSON(data any) error {
if err != nil {
return fmt.Errorf("failed to format JSON: %w", err)
}
- fmt.Println(colorizeJSON(output))
+ fmt.Println(ColorizeJSON(output))
return nil
}