1package cli
2
3import (
4 "encoding/csv"
5 "encoding/json"
6 "flag"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11
12 "github.com/floatpane/matcha/config"
13 "golang.org/x/term"
14)
15
16func RunContactsExport(args []string) error {
17 fs := flag.NewFlagSet("contacts export", flag.ExitOnError)
18 format := fs.String("f", "json", "output format: json or csv")
19 output := fs.String("o", "", "output file path (default: stdout)")
20 noHeader := fs.Bool("no-header", false, "omit CSV header row")
21 help := fs.Bool("h", false, "show help")
22
23 if err := fs.Parse(args); err != nil {
24 return err
25 }
26
27 if *help {
28 fmt.Println("Usage: matcha contacts export [flags]")
29 fmt.Println("")
30 fmt.Println("Export contacts from cache to JSON or CSV format.")
31 fmt.Println("")
32 fmt.Println("Flags:")
33 fs.PrintDefaults()
34 fmt.Println("")
35 fmt.Println("Examples:")
36 fmt.Println(" matcha contacts export # JSON to stdout")
37 fmt.Println(" matcha contacts export -f csv # CSV to stdout")
38 fmt.Println(" matcha contacts export -o out.json # JSON to file")
39 fmt.Println(" matcha contacts export -f csv --no-header # CSV without headers")
40 return nil
41 }
42
43 formatStr := strings.ToLower(*format)
44 if formatStr != "json" && formatStr != "csv" {
45 return fmt.Errorf("invalid format '%s': must be 'json' or 'csv'", *format)
46 }
47
48 return runExportContacts(formatStr, *output, *noHeader)
49}
50
51func runExportContacts(format, outputPath string, noHeader bool) error {
52 var contacts []config.Contact
53 var err error
54
55 if config.IsSecureModeEnabled() {
56 password, err := promptForPassword()
57 if err != nil {
58 return fmt.Errorf("password prompt failed: %w", err)
59 }
60
61 key, err := config.VerifyPassword(password)
62 if err != nil {
63 return fmt.Errorf("incorrect password")
64 }
65
66 config.SetSessionKey(key)
67 }
68
69 contactsCache, err := config.LoadContactsCache()
70 if err != nil {
71 path, pathErr := config.GetContactsCachePath()
72 if pathErr != nil {
73 return fmt.Errorf("contacts cache not found")
74 }
75 return fmt.Errorf("contacts cache not found at %s", path)
76 }
77
78 contacts = contactsCache.Contacts
79
80 if len(contacts) == 0 {
81 fmt.Fprintln(os.Stderr, "No contacts found in cache")
82 return nil
83 }
84
85 var outputData []byte
86 if format == "json" {
87 if noHeader {
88 return fmt.Errorf("the --no-header flag is only valid with CSV format")
89 }
90 outputData, err = exportToJSON(contacts)
91 if err != nil {
92 return fmt.Errorf("failed to export to JSON: %w", err)
93 }
94 } else {
95 outputData, err = exportToCSV(contacts, noHeader)
96 if err != nil {
97 return fmt.Errorf("failed to export to CSV: %w", err)
98 }
99 }
100
101 if outputPath != "" {
102 dir := filepath.Dir(outputPath)
103 if dir != "." {
104 if err := os.MkdirAll(dir, 0750); err != nil {
105 return fmt.Errorf("failed to create output directory: %w", err)
106 }
107 }
108 if err := os.WriteFile(outputPath, outputData, 0644); err != nil {
109 return fmt.Errorf("failed to write output file: %w", err)
110 }
111 fmt.Fprintf(os.Stderr, "Exported %d contacts to %s\n", len(contacts), outputPath)
112 } else {
113 fmt.Println(string(outputData))
114 }
115
116 return nil
117}
118
119func exportToJSON(contacts []config.Contact) ([]byte, error) {
120 return json.MarshalIndent(contacts, "", " ")
121}
122
123func exportToCSV(contacts []config.Contact, noHeader bool) ([]byte, error) {
124 var buf strings.Builder
125 writer := csv.NewWriter(&buf)
126
127 if !noHeader {
128 if err := writer.Write([]string{"name", "email", "last_used", "use_count"}); err != nil {
129 return nil, err
130 }
131 }
132
133 for _, c := range contacts {
134 usage := config.ContactAggregateUsage(c)
135 record := []string{
136 escapeCSV(c.Name),
137 escapeCSV(c.Email),
138 usage.LastUsed.Format("2006-01-02T15:04:05Z07:00"),
139 fmt.Sprintf("%d", usage.UseCount),
140 }
141 if err := writer.Write(record); err != nil {
142 return nil, err
143 }
144 }
145
146 writer.Flush()
147 return []byte(buf.String()), writer.Error()
148}
149
150func escapeCSV(s string) string {
151 if strings.ContainsAny(s, `,"`) {
152 return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
153 }
154 return s
155}
156
157func promptForPassword() (string, error) {
158 fmt.Print("Enter your password: ")
159 return readPassword()
160}
161
162func readPassword() (string, error) {
163 fmt.Print("(masked input) ")
164 password, err := term.ReadPassword(int(os.Stdin.Fd()))
165 fmt.Println() // newline after password
166 if err != nil {
167 return "", err
168 }
169 return string(password), nil
170}