From 417dbfd763d7c6772de66c1835886094676464c6 Mon Sep 17 00:00:00 2001 From: Freddy Ewald Date: Fri, 27 May 2022 14:21:12 -0700 Subject: [PATCH] Text display of conversations --- cmd/conversation.go | 79 ++++++++++++++--- cmd/root.go | 23 ++++- cmd/statistics.go | 14 +-- pkg/db.go | 148 +++++++++++++++++++++++++++++--- pkg/formatter.go | 16 ++-- templates/conversation-list.tpl | 5 ++ templates/conversation.tpl | 11 +++ 7 files changed, 246 insertions(+), 50 deletions(-) create mode 100644 templates/conversation-list.tpl create mode 100644 templates/conversation.tpl diff --git a/cmd/conversation.go b/cmd/conversation.go index f0c3759..361d129 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -5,10 +5,12 @@ import ( hermes "github.com/f-ewald/hermes/pkg" "github.com/spf13/cobra" "log" + "strconv" ) func init() { rootCmd.AddCommand(conversationCmd) + conversationCmd.AddCommand(conversationGetCmd) conversationCmd.AddCommand(conversationListCmd) // Here you will define your flags and configuration settings. @@ -24,19 +26,66 @@ func init() { // conversationCmd represents the conversation command. var conversationCmd = &cobra.Command{ - Use: "conversation", - Short: "Show conversations, find participants", + Use: "conversation", + Short: "Show retrieveConversations, find participants", + Aliases: []string{"retrieveConversations"}, //Long: `TODO`, //Run: func(cmd *cobra.Command, args []string) { // fmt.Println("conversation called") //}, } +func retrieveConversations() []*hermes.Chat { + messageDB, err := hermes.MessageDBFilename() + if err != nil { + log.Fatal(err) + } + db := hermes.NewDatabase(messageDB) + err = db.Connect() + if err != nil { + log.Fatal(err) + } + defer func() { + err = db.Close() + if err != nil { + log.Fatal(err) + } + }() + + handles, err := db.ListConversations(context.Background()) + if err != nil { + log.Fatal(err) + } + return handles +} + var conversationListCmd = &cobra.Command{ Use: "list", - Short: "List all conversations", - Long: "List all conversations with ", + Short: "List all retrieveConversations", + Long: "List all retrieveConversations", + Run: func(cmd *cobra.Command, args []string) { + chats := retrieveConversations() + + output, err := cfg.Formatter.Format(chats, "conversation-list.tpl") + if err != nil { + panic(err) + } + var printer hermes.Printer + printer = &hermes.StdoutPrinter{} + printer.Print(output) + }, +} + +var conversationGetCmd = &cobra.Command{ + Use: "get NUMBER ... ", + Short: "Get conversation", + Long: "Get conversation", + Args: cobra.MinimumNArgs(1), + ValidArgs: []string{"NUMBER"}, Run: func(cmd *cobra.Command, args []string) { + var printer hermes.Printer + printer = &hermes.StdoutPrinter{} + messageDB, err := hermes.MessageDBFilename() if err != nil { log.Fatal(err) @@ -53,14 +102,20 @@ var conversationListCmd = &cobra.Command{ } }() - conversations, err := db.ListConversations(context.Background()) - if err != nil { - log.Fatal(err) - } - var printer hermes.Printer - printer = &hermes.StdoutPrinter{} - for _, conversation := range conversations { - printer.Print([]byte(conversation)) + for _, arg := range args { + chatID, err := strconv.Atoi(arg) + if err != nil { + panic(err) + } + conversations, err := db.Conversation(context.Background(), chatID) + if err != nil { + panic(err) + } + b, err := cfg.Formatter.Format(conversations, "conversation.tpl") + if err != nil { + panic(err) + } + printer.Print(b) } }, } diff --git a/cmd/root.go b/cmd/root.go index f9c04de..6d2f5d6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + hermes "github.com/f-ewald/hermes/pkg" "github.com/spf13/cobra" "os" @@ -11,12 +12,16 @@ import ( var cfgFile string +var output string + // Config bundles the configuration. type Config struct { // Output defines the output format. If nothing is defined, normal text will be written to // STDOUT. Output string + Formatter hermes.Formatter + // ChatDB contains the full path to the chat database. ChatDB string } @@ -29,7 +34,19 @@ var rootCmd = &cobra.Command{ Use: "hermes", Short: "Command-line interface for iMessage databases", Long: `Hermes is a command-line interface for iMessage databases. -You can use it to analyze and display conversations and view statistics.`, +You can use it to analyze and display retrieveConversations and view statistics.`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + switch output { + case "text": + cfg.Formatter = &hermes.TextFormatter{} + case "json": + cfg.Formatter = &hermes.JsonFormatter{} + case "yaml": + cfg.Formatter = &hermes.YamlFormatter{} + default: + panic("output must be one of json, text or yaml") + } + }, } // Execute adds all child commands to the root command and sets flags appropriately. @@ -42,8 +59,8 @@ func init() { cobra.OnInitialize(initConfig) rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.hermes.yaml)") - rootCmd.PersistentFlags().StringVarP(&cfg.Output, "output", "o", "text", "The output format. Can be either json, yaml or text") - rootCmd.PersistentFlags().StringVar(&cfg.ChatDB, "db", "", "Full path to the chat database if it is different than the default path.") + rootCmd.PersistentFlags().StringVarP(&output, "output", "o", "text", "The output format. Can be either json, yaml or text") + rootCmd.PersistentFlags().StringVarP(&cfg.ChatDB, "database", "d", "", "Full path to the chat database if it is different than the default path.") } // initConfig reads in config file and ENV variables if set. diff --git a/cmd/statistics.go b/cmd/statistics.go index cf79b38..7b22449 100644 --- a/cmd/statistics.go +++ b/cmd/statistics.go @@ -18,18 +18,6 @@ var statisticsCmd = &cobra.Command{ Long: `Analyze all messages that have been sent and received and display the statistics. Supports text, JSON and YAML output format.`, Run: func(cmd *cobra.Command, args []string) { - var formatter hermes.Formatter - switch cfg.Output { - case "text": - formatter = &hermes.TextFormatter{} - case "json": - formatter = &hermes.JsonFormatter{} - case "yaml": - formatter = &hermes.YamlFormatter{} - default: - panic("invalid output format") - } - messageDB, err := hermes.MessageDBFilename() if err != nil { log.Fatal(err) @@ -50,7 +38,7 @@ Supports text, JSON and YAML output format.`, if err != nil { log.Fatal(err) } - formatted, err := formatter.Format(stats) + formatted, err := cfg.Formatter.Format(stats, "statistics.tpl") if err != nil { log.Fatal(err) } diff --git a/pkg/db.go b/pkg/db.go index fe0ba6a..2adba6b 100644 --- a/pkg/db.go +++ b/pkg/db.go @@ -4,6 +4,9 @@ import ( "context" "database/sql" _ "github.com/mattn/go-sqlite3" + "sort" + "strings" + "time" ) type Database struct { @@ -79,35 +82,156 @@ func (db *Database) Statistics(ctx context.Context) (stats *Statistics, err erro return stats, nil } -type Conversation struct { - Messages []*Message `json:"messages"` +// Participant is represented by a handle in the database +type Participant struct { + ID int `json:"id" yaml:"id"` + Number string `json:"number" yaml:"number"` + Country string `json:"country" yaml:"country"` + Service string `json:"service" yaml:"service"` +} + +// Chat has a unique ID and at least one participant. +type Chat struct { + ID int `json:"id" yaml:"id"` + Participants []*Participant `json:"participants,omitempty" yaml:"participants,omitempty"` + Messages []*Message `json:"messages,omitempty" yaml:"messages,omitempty"` } type Message struct { - Text string `json:"text"` + SenderID int `json:"sender_id" yaml:"sender-id"` + Text string `json:"text" yaml:"text"` + Date time.Time `json:"date" yaml:"date"` } -func (db *Database) Conversation(ctx context.Context) (conversation *Conversation, err error) { +// Conversation returns all messages from a conversation with the given identifier if available. +func (db *Database) Conversation(ctx context.Context, chatID int) (chat *Chat, err error) { + rows, err := db.db.QueryContext(ctx, `SELECT + datetime (message.date / 1000000000 + strftime ("%s", "2001-01-01"), "unixepoch", "localtime") AS message_date, + message.text, + message.is_from_me, + handle."ROWID", + handle.id, + handle.country, + handle.service +FROM + chat + JOIN chat_message_join ON chat. "ROWID" = chat_message_join.chat_id + JOIN message ON chat_message_join.message_id = message. "ROWID" + LEFT JOIN handle on message.handle_id = handle."ROWID" +WHERE + chat."ROWID" = ?`, chatID) + if err != nil { + return nil, err + } + defer func() { + _ = rows.Close() + }() + + participantMap := make(map[int]*Participant) + chat = &Chat{ + Participants: make([]*Participant, 0), + Messages: make([]*Message, 0), + } + for rows.Next() { + var dateString string + p := &Participant{} + m := &Message{} + var isFromMe bool + var text sql.NullString + var participantID sql.NullInt64 + var participantNumber sql.NullString + var participantCountry sql.NullString + var participantService sql.NullString + err = rows.Scan(&dateString, &text, &isFromMe, &participantID, &participantNumber, &participantCountry, + &participantService) + if err != nil { + return nil, err + } + + if participantID.Valid { + p.ID = int(participantID.Int64) + } + if participantNumber.Valid { + p.Number = participantNumber.String + } + if participantCountry.Valid { + p.Country = participantCountry.String + } + if participantService.Valid { + p.Service = participantService.String + } - return nil, nil + // Copy sender ID to message + m.SenderID = p.ID + + if text.Valid { + m.Text = text.String + } + m.Date, err = time.Parse("2006-01-02 15:04:05", dateString) + if err != nil { + panic(err) + } + + chat.Messages = append(chat.Messages, m) + + if p.ID != 0 { + if _, ok := participantMap[p.ID]; !ok { + participantMap[p.ID] = p + } + } + } + + for _, p := range participantMap { + chat.Participants = append(chat.Participants, p) + } + + return chat, nil } -func (db *Database) ListConversations(ctx context.Context) (conversations []string, err error) { - rows, err := db.db.QueryContext(ctx, "SELECT guid FROM chat") +func (db *Database) ListConversations(ctx context.Context) (chats []*Chat, err error) { + rows, err := db.db.QueryContext(ctx, `SELECT + chat."ROWID", handle."ROWID", handle.id, handle.country, handle.service +FROM + chat + JOIN chat_handle_join ON chat."ROWID" = chat_handle_join.chat_id + JOIN handle ON chat_handle_join.handle_id = handle."ROWID"`) if err != nil { return nil, err } defer func() { _ = rows.Close() }() - conversations = make([]string, 0) + chatMap := make(map[int]*Chat) for rows.Next() { - var guid string - err = rows.Scan(&guid) + var chatID int + var p Participant + err = rows.Scan(&chatID, &p.ID, &p.Number, &p.Country, &p.Service) if err != nil { return nil, err } - conversations = append(conversations, guid) + + // Normalize values + p.Service = strings.ToUpper(p.Service) + p.Country = strings.ToUpper(p.Country) + + if _, ok := chatMap[chatID]; !ok { + chatMap[chatID] = &Chat{ + ID: chatID, + Participants: make([]*Participant, 0), + } + } + chatMap[chatID].Participants = append(chatMap[chatID].Participants, &p) } - return conversations, nil + + chats = make([]*Chat, 0) + for _, chat := range chatMap { + chats = append(chats, chat) + } + + // Sort chats by ID so that they are always shown in the same order. + sort.Slice(chats, func(i, j int) bool { + return chats[i].ID < chats[j].ID + }) + + return chats, nil } diff --git a/pkg/formatter.go b/pkg/formatter.go index 2113c31..565cb11 100644 --- a/pkg/formatter.go +++ b/pkg/formatter.go @@ -5,23 +5,19 @@ import ( "encoding/json" "github.com/f-ewald/hermes/templates" "gopkg.in/yaml.v2" - "html/template" + "text/template" ) type Formatter interface { - Format(interface{}) ([]byte, error) + Format(a interface{}, tpl string) ([]byte, error) } type TextFormatter struct { tpl string } -func NewTextFormatter(tpl string) Formatter { - return &TextFormatter{tpl: tpl} -} - -func (formatter *TextFormatter) Format(a interface{}) ([]byte, error) { - b, err := templates.Templates.ReadFile("statistics.tpl") +func (formatter *TextFormatter) Format(a interface{}, tpl string) ([]byte, error) { + b, err := templates.Templates.ReadFile(tpl) if err != nil { return nil, err } @@ -39,12 +35,12 @@ func (formatter *TextFormatter) Format(a interface{}) ([]byte, error) { type JsonFormatter struct{} -func (formatter *JsonFormatter) Format(a interface{}) ([]byte, error) { +func (formatter *JsonFormatter) Format(a interface{}, _ string) ([]byte, error) { return json.Marshal(a) } type YamlFormatter struct{} -func (formatter *YamlFormatter) Format(a interface{}) ([]byte, error) { +func (formatter *YamlFormatter) Format(a interface{}, _ string) ([]byte, error) { return yaml.Marshal(a) } diff --git a/templates/conversation-list.tpl b/templates/conversation-list.tpl new file mode 100644 index 0000000..92aa948 --- /dev/null +++ b/templates/conversation-list.tpl @@ -0,0 +1,5 @@ +Conversations (ID: Participant, ...) +==================================== +{{ range . -}} +{{ .ID }}: {{ range $i, $e := .Participants }}{{if $i}}, {{end}}{{$e.Number}}{{ end }} +{{ end }} \ No newline at end of file diff --git a/templates/conversation.tpl b/templates/conversation.tpl new file mode 100644 index 0000000..ce691ff --- /dev/null +++ b/templates/conversation.tpl @@ -0,0 +1,11 @@ +Conversation +============ +Participants: +{{ range .Participants -}} +* {{ .ID }}: {{ .Number }} +{{- end }} + +Messages: +{{ range .Messages -}} +{{printf "%05d" .SenderID}} ({{.Date}}): {{ .Text }} +{{ end }} \ No newline at end of file