discrawl/internal/cli/output.go
2026-05-05 14:59:11 -07:00

458 lines
14 KiB
Go

package cli
import (
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/openclaw/discrawl/internal/discorddesktop"
"github.com/openclaw/discrawl/internal/report"
"github.com/openclaw/discrawl/internal/store"
"github.com/openclaw/discrawl/internal/syncer"
)
func (r *runtime) print(value any) error {
if r.json {
enc := json.NewEncoder(r.stdout)
enc.SetIndent("", " ")
return enc.Encode(value)
}
if r.plain {
if err := printPlain(r.stdout, value); err == nil {
return nil
}
}
if err := printHuman(r.stdout, value); err == nil {
return nil
}
enc := json.NewEncoder(r.stdout)
enc.SetIndent("", " ")
return enc.Encode(value)
}
func printPlain(w io.Writer, value any) error {
switch v := value.(type) {
case []store.SearchResult:
for _, row := range v {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", row.GuildID, row.ChannelID, row.AuthorID, row.Content)
}
return nil
case []store.MemberRow:
for _, row := range v {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", row.GuildID, row.UserID, row.Username)
}
return nil
case store.MemberProfile:
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", v.Member.GuildID, v.Member.UserID, v.Member.Username)
return nil
case []store.ChannelRow:
for _, row := range v {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", row.GuildID, row.ID, row.Kind, row.Name)
}
return nil
case []store.MessageRow:
for _, row := range v {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", formatTime(row.CreatedAt), row.GuildID, row.ChannelID, row.AuthorID, row.MessageID, row.Content)
}
return nil
case []store.DirectMessageConversationRow:
for _, row := range v {
_, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\t%s\n", row.ChannelID, row.Name, row.MessageCount, row.AuthorCount, formatTime(row.FirstMessageAt), formatTime(row.LastMessageAt))
}
return nil
case []store.MentionRow:
for _, row := range v {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", formatTime(row.CreatedAt), row.GuildID, row.ChannelID, row.AuthorID, row.TargetType, row.TargetID, row.Content)
}
return nil
case report.Digest:
for _, row := range v.Channels {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%d\t%d\n", row.ChannelID, row.ChannelName, row.Kind, row.GuildID, row.Messages, row.Replies, row.ActiveAuthors)
}
return nil
case report.Quiet:
for _, row := range v.Channels {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", row.ChannelID, row.ChannelName, row.Kind, row.GuildID, row.LastMessage, row.DaysSilent)
}
return nil
case report.Trends:
for _, row := range v.Rows {
for _, week := range row.Weekly {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", row.GuildID, row.ChannelID, row.ChannelName, row.Kind, formatTime(week.WeekStart), week.Messages)
}
}
return nil
default:
return errors.New("no plain printer")
}
}
func printUsage(w io.Writer) {
_, _ = fmt.Fprint(w, `discrawl archives Discord guild data into local SQLite.
Usage:
discrawl [global flags] <command> [args]
Commands:
metadata
version
init
sync
tail
tap
cache-import
wiretap
search
tui
messages
digest
analytics
dms
mentions
embed
sql
members
channels
status
report
doctor
`)
}
func printRows(w io.Writer, cols []string, rows [][]string) error {
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
_, _ = fmt.Fprintln(tw, strings.Join(cols, "\t"))
for _, row := range rows {
_, _ = fmt.Fprintln(tw, strings.Join(row, "\t"))
}
return tw.Flush()
}
func printHuman(w io.Writer, value any) error {
switch v := value.(type) {
case syncRunStats:
if _, err := fmt.Fprintf(w, "source=%s\n", v.Source); err != nil {
return err
}
if v.Discord != nil {
if _, err := fmt.Fprintf(w, "discord_guilds=%d\ndiscord_channels=%d\ndiscord_threads=%d\ndiscord_members=%d\ndiscord_messages=%d\n",
v.Discord.Guilds, v.Discord.Channels, v.Discord.Threads, v.Discord.Members, v.Discord.Messages); err != nil {
return err
}
}
if v.Wiretap != nil {
if _, err := fmt.Fprintf(w, "wiretap_visited=%d\nwiretap_files=%d\nwiretap_unchanged=%d\nwiretap_fast_skipped=%d\nwiretap_messages=%d\nwiretap_dm_messages=%d\nwiretap_dm_channels=%d\nwiretap_guild_messages=%d\nwiretap_skipped_messages=%d\nwiretap_skipped_channels=%d\nwiretap_checkpoints=%d\n",
v.Wiretap.FilesVisited, v.Wiretap.FilesScanned, v.Wiretap.FilesUnchanged, v.Wiretap.CacheFilesFastSkipped, v.Wiretap.Messages, v.Wiretap.DMMessages, v.Wiretap.DMChannels, v.Wiretap.GuildMessages, v.Wiretap.SkippedMessages, v.Wiretap.SkippedChannels, v.Wiretap.Checkpoints); err != nil {
return err
}
}
return nil
case syncer.SyncStats:
_, err := fmt.Fprintf(w, "guilds=%d channels=%d threads=%d members=%d messages=%d\n", v.Guilds, v.Channels, v.Threads, v.Members, v.Messages)
return err
case discorddesktop.Stats:
_, err := fmt.Fprintf(w, "path=%s\nvisited=%d\nfiles=%d\nskipped=%d\nunchanged=%d\nfast_skipped=%d\nobjects=%d\nguilds=%d\nchannels=%d\nmessages=%d\ndm_messages=%d\ndm_channels=%d\nguild_messages=%d\nskipped_messages=%d\nskipped_channels=%d\ncheckpoints=%d\nfull_cache=%t\ndry_run=%t\n",
v.Path, v.FilesVisited, v.FilesScanned, v.FilesSkipped, v.FilesUnchanged, v.CacheFilesFastSkipped, v.JSONObjects, v.Guilds, v.Channels, v.Messages, v.DMMessages, v.DMChannels, v.GuildMessages, v.SkippedMessages, v.SkippedChannels, v.Checkpoints, v.FullCache, v.DryRun)
return err
case store.Status:
_, err := fmt.Fprintf(w, "db=%s\nguilds=%d\nchannels=%d\nthreads=%d\nmessages=%d\nmembers=%d\nembedding_backlog=%d\nlast_sync=%s\nlast_tail_event=%s\n",
v.DBPath, v.GuildCount, v.ChannelCount, v.ThreadCount, v.MessageCount, v.MemberCount, v.EmbeddingBacklog,
formatTime(v.LastSyncAt), formatTime(v.LastTailEventAt))
return err
case store.EmbeddingDrainStats:
_, err := fmt.Fprintf(w, "processed=%d\nsucceeded=%d\nfailed=%d\nskipped=%d\nremaining_backlog=%d\nprovider=%s\nmodel=%s\ninput_version=%s\n",
v.Processed, v.Succeeded, v.Failed, v.Skipped, v.RemainingBacklog, v.Provider, v.Model, v.InputVersion)
if err != nil {
return err
}
if v.Requeued > 0 {
if _, err := fmt.Fprintf(w, "requeued=%d\n", v.Requeued); err != nil {
return err
}
}
if v.RateLimited {
_, err = fmt.Fprintln(w, "rate_limited=true")
}
return err
case []store.SearchResult:
for _, row := range v {
if _, err := fmt.Fprintf(w, "[%s/%s] %s %s\n%s\n\n", row.GuildID, row.ChannelName, row.AuthorName, formatTime(row.CreatedAt), row.Content); err != nil {
return err
}
}
return nil
case []store.MessageRow:
for _, row := range v {
if _, err := fmt.Fprintf(w, "[%s/%s] %s %s\n%s\n\n", row.GuildID, row.ChannelName, row.AuthorName, formatTime(row.CreatedAt), row.Content); err != nil {
return err
}
}
return nil
case []store.DirectMessageConversationRow:
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
_, _ = fmt.Fprintln(tw, "CHANNEL\tNAME\tMESSAGES\tAUTHORS\tFIRST\tLAST")
for _, row := range v {
_, _ = fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%s\t%s\n",
row.ChannelID,
row.Name,
row.MessageCount,
row.AuthorCount,
formatTime(row.FirstMessageAt),
formatTime(row.LastMessageAt),
)
}
return tw.Flush()
case []store.MentionRow:
for _, row := range v {
if _, err := fmt.Fprintf(w, "[%s/%s] %s -> %s:%s %s\n%s\n\n", row.GuildID, row.ChannelName, row.AuthorName, row.TargetType, firstNonEmpty(row.TargetName, row.TargetID), formatTime(row.CreatedAt), row.Content); err != nil {
return err
}
}
return nil
case []store.MemberRow:
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
_, _ = fmt.Fprintln(tw, "GUILD\tUSER\tNAME\tDISPLAY\tPROFILE")
for _, row := range v {
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
row.GuildID,
row.UserID,
row.Username,
firstNonEmpty(row.DisplayName, row.Nick, row.GlobalName),
memberProfileSummary(row),
)
}
return tw.Flush()
case store.MemberProfile:
if _, err := fmt.Fprintf(w, "guild=%s\nuser=%s\nusername=%s\ndisplay=%s\njoined=%s\nbot=%t\n",
v.Member.GuildID,
v.Member.UserID,
v.Member.Username,
firstNonEmpty(v.Member.DisplayName, v.Member.Nick, v.Member.GlobalName),
formatTime(v.Member.JoinedAt),
v.Member.Bot,
); err != nil {
return err
}
if v.Member.XHandle != "" {
if _, err := fmt.Fprintf(w, "x=%s\n", v.Member.XHandle); err != nil {
return err
}
}
if v.Member.GitHubLogin != "" {
if _, err := fmt.Fprintf(w, "github=%s\n", v.Member.GitHubLogin); err != nil {
return err
}
}
if v.Member.Website != "" {
if _, err := fmt.Fprintf(w, "website=%s\n", v.Member.Website); err != nil {
return err
}
}
if v.Member.Pronouns != "" {
if _, err := fmt.Fprintf(w, "pronouns=%s\n", v.Member.Pronouns); err != nil {
return err
}
}
if v.Member.Location != "" {
if _, err := fmt.Fprintf(w, "location=%s\n", v.Member.Location); err != nil {
return err
}
}
if v.Member.Bio != "" {
if _, err := fmt.Fprintf(w, "bio=%s\n", v.Member.Bio); err != nil {
return err
}
}
if len(v.Member.URLs) > 0 {
if _, err := fmt.Fprintf(w, "urls=%s\n", strings.Join(v.Member.URLs, ", ")); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, "message_count=%d\nfirst_message=%s\nlast_message=%s\n",
v.MessageCount,
formatTime(v.FirstMessageAt),
formatTime(v.LastMessageAt),
); err != nil {
return err
}
if len(v.RecentMessages) == 0 {
return nil
}
if _, err := fmt.Fprintln(w, "\nRecent messages:"); err != nil {
return err
}
for _, row := range v.RecentMessages {
if _, err := fmt.Fprintf(w, "[%s] %s\n%s\n\n", row.ChannelName, formatTime(row.CreatedAt), row.Content); err != nil {
return err
}
}
return nil
case []store.ChannelRow:
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
_, _ = fmt.Fprintln(tw, "GUILD\tCHANNEL\tKIND\tNAME")
for _, row := range v {
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", row.GuildID, row.ID, row.Kind, row.Name)
}
return tw.Flush()
case report.Digest:
for _, channel := range v.Channels {
if _, err := fmt.Fprintf(w, "%s (%s)\n", channel.ChannelName, firstNonEmpty(channel.Kind, "unknown")); err != nil {
return err
}
if _, err := fmt.Fprintf(w, " messages=%d replies=%d authors=%d\n", channel.Messages, channel.Replies, channel.ActiveAuthors); err != nil {
return err
}
if _, err := fmt.Fprintf(w, " top posters %s\n", formatRankedCounts(channel.TopPosters)); err != nil {
return err
}
if _, err := fmt.Fprintf(w, " top mentions %s\n\n", formatRankedCounts(channel.TopMentions)); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, "Window: %s to %s (%s)\n", formatTime(v.Since), formatTime(v.Until), v.WindowLabel); err != nil {
return err
}
_, err := fmt.Fprintf(w, "Totals: messages=%d replies=%d channels=%d authors=%d\n", v.Totals.Messages, v.Totals.Replies, v.Totals.Channels, v.Totals.ActiveAuthors)
return err
case report.Quiet:
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
_, _ = fmt.Fprintln(tw, "CHANNEL\tKIND\tLAST MESSAGE\tDAYS SILENT")
for _, row := range v.Channels {
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
row.ChannelName,
firstNonEmpty(row.Kind, "unknown"),
firstNonEmpty(row.LastMessage, "never"),
formatDaysSilent(row.DaysSilent),
)
}
if err := tw.Flush(); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "\nWindow: %s to %s (%s)\n", formatTime(v.Since), formatTime(v.Until), formatWindowDuration(v.Until.Sub(v.Since))); err != nil {
return err
}
_, err := fmt.Fprintf(w, "Totals: channels=%d\n", v.Totals.Channels)
return err
case report.Trends:
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
header := []string{"CHANNEL", "KIND", "TOTAL"}
weekStarts := make([]time.Time, 0, v.Weeks)
if len(v.Rows) > 0 {
for _, week := range v.Rows[0].Weekly {
weekStarts = append(weekStarts, week.WeekStart)
}
} else {
for i := range v.Weeks {
weekStarts = append(weekStarts, v.Since.AddDate(0, 0, 7*i))
}
}
for _, start := range weekStarts {
header = append(header, start.Format(time.DateOnly))
}
_, _ = fmt.Fprintln(tw, strings.Join(header, "\t"))
for _, row := range v.Rows {
cols := []string{row.ChannelName, firstNonEmpty(row.Kind, "unknown"), strconv.Itoa(trendsRowTotal(row.Weekly))}
for _, week := range row.Weekly {
cols = append(cols, strconv.Itoa(week.Messages))
}
_, _ = fmt.Fprintln(tw, strings.Join(cols, "\t"))
}
if err := tw.Flush(); err != nil {
return err
}
_, err := fmt.Fprintf(w, "\nWindow: %s to %s (%d weeks)\n", formatTime(v.Since), formatTime(v.Until), v.Weeks)
return err
case map[string]any:
keys := make([]string, 0, len(v))
for key := range v {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if _, err := fmt.Fprintf(w, "%s=%v\n", key, v[key]); err != nil {
return err
}
}
return nil
default:
return errors.New("no human printer")
}
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
func memberProfileSummary(row store.MemberRow) string {
parts := []string{}
if row.XHandle != "" {
parts = append(parts, "x:"+row.XHandle)
}
if row.GitHubLogin != "" {
parts = append(parts, "gh:"+row.GitHubLogin)
}
if row.Website != "" {
parts = append(parts, row.Website)
}
if row.Bio != "" {
parts = append(parts, trimForTable(row.Bio))
}
return strings.Join(parts, " | ")
}
func trimForTable(value string) string {
value = strings.TrimSpace(value)
if len(value) <= 40 {
return value
}
return value[:37] + "..."
}
func formatRankedCounts(rows []report.RankedCount) string {
if len(rows) == 0 {
return "-"
}
parts := make([]string, 0, len(rows))
for _, row := range rows {
parts = append(parts, fmt.Sprintf("%s (%d)", firstNonEmpty(row.Name, "unknown"), row.Count))
}
return strings.Join(parts, ", ")
}
func formatDaysSilent(days int) string {
if days < 0 {
return "-"
}
return strconv.Itoa(days)
}
func formatWindowDuration(d time.Duration) string {
if d <= 0 {
return "0"
}
if d%(24*time.Hour) == 0 {
return fmt.Sprintf("%dd", int(d/(24*time.Hour)))
}
if d%time.Hour == 0 {
return fmt.Sprintf("%dh", int(d/time.Hour))
}
return d.String()
}
func trendsRowTotal(weekly []report.WeeklyCount) int {
total := 0
for _, row := range weekly {
total += row.Messages
}
return total
}