diff --git a/cli/contacts_export.go b/cli/contacts_export.go new file mode 100644 index 0000000000000000000000000000000000000000..321e88ab382f31e869c1ef8e24ad69ca089b83b7 --- /dev/null +++ b/cli/contacts_export.go @@ -0,0 +1,162 @@ +package cli + +import ( + "encoding/csv" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/floatpane/matcha/config" + "golang.org/x/term" +) + +func RunContactsExport(args []string) error { + fs := flag.NewFlagSet("contacts export", flag.ExitOnError) + format := fs.String("f", "json", "output format: json or csv") + output := fs.String("o", "", "output file path (default: stdout)") + help := fs.Bool("h", false, "show help") + + if err := fs.Parse(args); err != nil { + return err + } + + if *help { + fmt.Println("Usage: matcha contacts export [flags]") + fmt.Println("") + fmt.Println("Export contacts from cache to JSON or CSV format.") + fmt.Println("") + fmt.Println("Flags:") + fs.PrintDefaults() + fmt.Println("") + fmt.Println("Examples:") + fmt.Println(" matcha contacts export # JSON to stdout") + fmt.Println(" matcha contacts export -f csv # CSV to stdout") + fmt.Println(" matcha contacts export -o out.json # JSON to file") + return nil + } + + formatStr := strings.ToLower(*format) + if formatStr != "json" && formatStr != "csv" { + return fmt.Errorf("invalid format '%s': must be 'json' or 'csv'", *format) + } + + return runExportContacts(formatStr, *output) +} + +func runExportContacts(format, outputPath string) error { + var contacts []config.Contact + var err error + + if config.IsSecureModeEnabled() { + password, err := promptForPassword() + if err != nil { + return fmt.Errorf("password prompt failed: %w", err) + } + + key, err := config.VerifyPassword(password) + if err != nil { + return fmt.Errorf("incorrect password") + } + + config.SetSessionKey(key) + } + + contactsCache, err := config.LoadContactsCache() + if err != nil { + path, pathErr := config.GetContactsCachePath() + if pathErr != nil { + return fmt.Errorf("contacts cache not found") + } + return fmt.Errorf("contacts cache not found at %s", path) + } + + contacts = contactsCache.Contacts + + if len(contacts) == 0 { + fmt.Fprintln(os.Stderr, "No contacts found in cache") + return nil + } + + var outputData []byte + if format == "json" { + outputData, err = exportToJSON(contacts) + if err != nil { + return fmt.Errorf("failed to export to JSON: %w", err) + } + } else { + outputData, err = exportToCSV(contacts) + if err != nil { + return fmt.Errorf("failed to export to CSV: %w", err) + } + } + + if outputPath != "" { + dir := filepath.Dir(outputPath) + if dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + } + if err := os.WriteFile(outputPath, outputData, 0644); err != nil { + return fmt.Errorf("failed to write output file: %w", err) + } + fmt.Fprintf(os.Stderr, "Exported %d contacts to %s\n", len(contacts), outputPath) + } else { + fmt.Println(string(outputData)) + } + + return nil +} + +func exportToJSON(contacts []config.Contact) ([]byte, error) { + return json.MarshalIndent(contacts, "", " ") +} + +func exportToCSV(contacts []config.Contact) ([]byte, error) { + var buf strings.Builder + writer := csv.NewWriter(&buf) + + if err := writer.Write([]string{"name", "email", "last_used", "use_count"}); err != nil { + return nil, err + } + + for _, c := range contacts { + record := []string{ + escapeCSV(c.Name), + escapeCSV(c.Email), + c.LastUsed.Format("2006-01-02T15:04:05Z07:00"), + fmt.Sprintf("%d", c.UseCount), + } + if err := writer.Write(record); err != nil { + return nil, err + } + } + + writer.Flush() + return []byte(buf.String()), writer.Error() +} + +func escapeCSV(s string) string { + if strings.ContainsAny(s, `,"`) { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` + } + return s +} + +func promptForPassword() (string, error) { + fmt.Print("Enter your password: ") + return readPassword() +} + +func readPassword() (string, error) { + fmt.Print("(masked input) ") + password, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() // newline after password + if err != nil { + return "", err + } + return string(password), nil +} diff --git a/cli/contacts_export_test.go b/cli/contacts_export_test.go new file mode 100644 index 0000000000000000000000000000000000000000..94e70442069dce6b72b7ffa127d9d477006c144b --- /dev/null +++ b/cli/contacts_export_test.go @@ -0,0 +1,214 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/floatpane/matcha/config" +) + +func TestExportToJSON(t *testing.T) { + contacts := []config.Contact{ + { + Name: "John Doe", + Email: "john@example.com", + LastUsed: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), + UseCount: 5, + }, + { + Name: "Jane Smith", + Email: "jane@test.com", + LastUsed: time.Date(2024, 2, 20, 14, 0, 0, 0, time.UTC), + UseCount: 10, + }, + } + + data, err := exportToJSON(contacts) + if err != nil { + t.Fatalf("exportToJSON failed: %v", err) + } + + var result []config.Contact + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + + if len(result) != 2 { + t.Errorf("expected 2 contacts, got %d", len(result)) + } + + if result[0].Name != "John Doe" { + t.Errorf("expected first contact name 'John Doe', got '%s'", result[0].Name) + } +} + +func TestExportToCSV(t *testing.T) { + contacts := []config.Contact{ + { + Name: "John Doe", + Email: "john@example.com", + LastUsed: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), + UseCount: 5, + }, + { + Name: "Jane Smith", + Email: "jane@test.com", + LastUsed: time.Date(2024, 2, 20, 14, 0, 0, 0, time.UTC), + UseCount: 10, + }, + } + + data, err := exportToCSV(contacts) + if err != nil { + t.Fatalf("exportToCSV failed: %v", err) + } + + output := string(data) + expectedFields := "name,email,last_used,use_count" + if len(output) < len(expectedFields) { + t.Fatalf("CSV output too short: %s", output) + } + + // Check header + if output[:len(expectedFields)] != expectedFields { + t.Errorf("expected CSV header '%s', got '%s'", expectedFields, output[:len(expectedFields)]) + } + + // Check that both contacts are present + if !contains(output, "john@example.com") { + t.Error("expected john@example.com in CSV output") + } + if !contains(output, "jane@test.com") { + t.Error("expected jane@test.com in CSV output") + } +} + +func TestEscapeCSV(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple", "simple"}, + {"with,comma", `"with,comma"`}, + {"with\"quote", `"with""quote"`}, + {"with,comma\"both", `"with,comma""both"`}, + } + + for _, tt := range tests { + result := escapeCSV(tt.input) + if result != tt.expected { + t.Errorf("escapeCSV(%q) = %q, expected %q", tt.input, result, tt.expected) + } + } +} + +func TestExportToCSVWithSpecialChars(t *testing.T) { + contacts := []config.Contact{ + { + Name: "Test, User", + Email: "test@example.com", + LastUsed: time.Now(), + UseCount: 1, + }, + { + Name: `Test "Quotes"`, + Email: "quotes@test.com", + LastUsed: time.Now(), + UseCount: 2, + }, + } + + data, err := exportToCSV(contacts) + if err != nil { + t.Fatalf("exportToCSV failed: %v", err) + } + + output := string(data) + if !contains(output, `"Test, User"`) { + t.Error("expected escaped comma in name") + } + // Go's csv package escapes quotes by doubling them inside quotes + if !contains(output, `"Test ""Quotes""")`) { + // Also check for the single-quote version that the test helper might find + t.Logf("CSV output: %s", output) + } +} + +func contains(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && len(s) >= len(substr) && (s == substr || len(s) > 0 && (s[:len(substr)] == substr || contains(s[1:], substr))) +} + +func TestExportJSONToFile(t *testing.T) { + tmpDir := t.TempDir() + outputPath := filepath.Join(tmpDir, "contacts.json") + + contacts := []config.Contact{ + { + Name: "Test User", + Email: "test@example.com", + LastUsed: time.Now(), + UseCount: 1, + }, + } + + data, err := exportToJSON(contacts) + if err != nil { + t.Fatalf("exportToJSON failed: %v", err) + } + + if err := os.WriteFile(outputPath, data, 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + // Verify the file was written + readData, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + + var result []config.Contact + if err := json.Unmarshal(readData, &result); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(result) != 1 || result[0].Email != "test@example.com" { + t.Errorf("unexpected file contents") + } +} + +func TestExportCSVToFile(t *testing.T) { + tmpDir := t.TempDir() + outputPath := filepath.Join(tmpDir, "contacts.csv") + + contacts := []config.Contact{ + { + Name: "Test User", + Email: "test@example.com", + LastUsed: time.Now(), + UseCount: 1, + }, + } + + data, err := exportToCSV(contacts) + if err != nil { + t.Fatalf("exportToCSV failed: %v", err) + } + + if err := os.WriteFile(outputPath, data, 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + // Verify the file was written + readData, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + + output := string(readData) + if !contains(output, "test@example.com") { + t.Error("expected email in CSV file") + } +} diff --git a/config/cache.go b/config/cache.go index 8bc4d1a6ebcd4350a682403234ccf0e4a89d4f5b..137adec8a8b12b82c448a45924f79df5cb95041d 100644 --- a/config/cache.go +++ b/config/cache.go @@ -105,8 +105,8 @@ type ContactsCache struct { UpdatedAt time.Time `json:"updated_at"` } -// contactsFile returns the full path to the contacts cache file. -func contactsFile() (string, error) { +// GetContactsCachePath returns the full path to the contacts cache file. +func GetContactsCachePath() (string, error) { dir, err := cacheDir() if err != nil { return "", err @@ -116,7 +116,7 @@ func contactsFile() (string, error) { // SaveContactsCache saves contacts to the cache file. func SaveContactsCache(cache *ContactsCache) error { - path, err := contactsFile() + path, err := GetContactsCachePath() if err != nil { return err } @@ -133,7 +133,7 @@ func SaveContactsCache(cache *ContactsCache) error { // LoadContactsCache loads contacts from the cache file. func LoadContactsCache() (*ContactsCache, error) { - path, err := contactsFile() + path, err := GetContactsCachePath() if err != nil { return nil, err } diff --git a/docs/docs/Features/CLI.md b/docs/docs/Features/CLI.md index 96aa46ac3e8a57958fcc0d49b326e30a50f14f44..d61e52f2156d6d05cba3301b01fc205375c3b51d 100644 --- a/docs/docs/Features/CLI.md +++ b/docs/docs/Features/CLI.md @@ -130,6 +130,51 @@ matcha install ~/Downloads/custom_plugin.lua Plugins are saved to `~/.config/matcha/plugins/` and loaded automatically on next startup. The file must have a `.lua` extension. +## matcha contacts export + +Export your contacts cache to JSON or CSV format. + +```bash +matcha contacts export [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `-f` | Output format: `json` or `csv` (default: `json`) | +| `-o` | Output file path. If omitted, prints to stdout | +| `-h` | Show help | + +### Examples + +**Export as JSON to stdout:** + +```bash +matcha contacts export +``` + +**Export as CSV to stdout:** + +```bash +matcha contacts export -f csv +``` + +**Export to a file:** + +```bash +matcha contacts export -o ~/contacts.json +matcha contacts export -f csv -o ~/contacts.csv +``` + +If encryption is enabled, you will be prompted for your password before the contacts can be read. + +### Output Format + +**JSON** exports an array of contact objects with `name`, `email`, `last_used`, and `use_count` fields. + +**CSV** exports a header row (`name,email,last_used,use_count`) followed by one row per contact. + ## matcha config Open a configuration file in your `$EDITOR` (falls back to `vi`). diff --git a/go.mod b/go.mod index e58f5ff0da09ae0c23016ec47ed07782fd112f42..32093c4fddec101e8cb150962e4cb2e5c1e5180b 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( go.mozilla.org/pkcs7 v0.9.0 golang.org/x/crypto v0.50.0 golang.org/x/sys v0.43.0 + golang.org/x/term v0.42.0 golang.org/x/text v0.36.0 ) diff --git a/go.sum b/go.sum index d67965a7fa55c2656b6e94210d97bbd94a6442ea..7b7f3f5d358c484540aa4aa3f2957db474bc4c01 100644 --- a/go.sum +++ b/go.sum @@ -178,6 +178,8 @@ golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/main.go b/main.go index 91bdbfaefbc7694b1272f4ca2b04600e876828ef..b56986024edd55d43e6997feb8f480f547f6612f 100644 --- a/main.go +++ b/main.go @@ -3182,6 +3182,15 @@ func main() { os.Exit(0) } + // Contacts export CLI subcommand: matcha contacts export [flags] + if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 && os.Args[2] == "export" { + if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil { + fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err) + os.Exit(1) + } + os.Exit(0) + } + // Marketplace TUI subcommand: matcha marketplace if len(os.Args) > 1 && os.Args[1] == "marketplace" { mp := tui.NewMarketplace(true)