contacts_export.go

  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}